Files
VolcanicAgenticCore/CLAUDE.md
EugeneTes 6e3241fed9 feat(scheduler): scheduled jobs inside skills (.volcanic/skills/<skill>/jobs/<job>.md)
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.
2026-10-14 12:54:40 +00:00

15 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

VolcanicAgenticCore is a headless, extensible agent harness for .NET 10, ported from earendil-works/pi without the terminal UI. It also ships a C# port of chem/pi-claude-code-provider, which drives the local claude CLI on a Claude subscription as a model provider. A Telegram bot is the first front end.

When porting behaviour, pi and pi-claude-code-provider are the reference: event names, JSON shapes and invariants deliberately match them.

Commands

dotnet build                                   # whole solution (VolcanicAgenticCore.slnx)
dotnet test                                    # all four test projects; no test calls a paid API
dotnet test tests/Volcanic.AgenticCore.Tests   # one project
dotnet test tests/Volcanic.AgenticCore.A2A.Server.Tests   # A2A server only
dotnet test --filter "FullyQualifiedName~AgentLoopTests"            # one class
dotnet test --filter "FullyQualifiedName~AgentLoopTests.SomeTest"   # one test

# reference host with the scripted faux provider (no keys)
dotnet run --project samples/Volcanic.AgenticCore.Sample.Headless -- --faux "Say hello"
dotnet run --project samples/Volcanic.AgenticCore.Sample.Headless -- --model claude-code/sonnet "List the files here"

# Telegram bot
TELEGRAM_BOT_TOKEN=123:abc TELEGRAM_ALLOWED_USER_IDS=111111111 dotnet run --project hosts/Volcanic.AgenticCore.Telegram

Directory.Build.props sets TreatWarningsAsErrors=true, nullable and implicit usings for every non-test project, so a new warning fails the build. Test projects turn warnings-as-errors off. Tests use xunit 2.

Architecture

Read docs/architecture.md first. Key points:

  • src/Volcanic.AgenticCore.Abstractions holds the contracts: messages, Model/IProvider, the streaming event protocol, ITool/Tool<TArgs>, IExtension/IExtensionApi and extension events, session entries/stores, IUserInterface, IChatChannel, and AgentSessionEvent. Extensions reference only this assembly, so it stays small and stable. Don't let runtime types leak into it.
  • src/Volcanic.AgenticCore is organised as vertical slices under Features/ (Agent, Sessions, Models, Tools, Extensions, Commands, SystemPrompt, Compaction, Processes). Each slice exposes one entry type (AgentSession, SessionManager, ModelRegistry, ToolRegistry, ExtensionRunner, CommandRegistry, SystemPromptBuilder, Compactor, ProcessSpawnPolicy). Slices talk only through those entry types and the Abstractions. Hosting/ has AddAgenticCore(...) and AgenticCoreOptions; Testing/FauxProvider.cs is the scripted provider that tests and --faux use.
  • src/Volcanic.AgenticCore.A2A.Server exposes assistants over A2A (docs/a2a.md) on the official SDK, built directly on the core (IAgentSessionFactory), not the extension API: one VolcanicA2AServer per authenticated caller (A2AServerCache), routed by A2ARequestRouter, one AgentSession per (caller, contextId) (A2ASessionPool). samples/Volcanic.AgenticCore.Sample.A2A mounts it over one workspace for local use.
  • One conversation = one AgentSession, built by IAgentSessionFactory.CreateAsync. Extensions are instantiated per session and each session has its own ModelRegistry, so provider registrations and extension fields are per conversation. Process-wide state has to be explicit (e.g. ClaudeLoginCoordinator.Shared). AgentSession.SetTimeZone changes the session's zone (system prompt from the next run); see "Time zone" in docs/architecture.md.
  • Two event systems. AgentSessionEvents go to hosts via session.Subscribe and are invoked synchronously on the loop. ExtensionEvents go to extensions via api.On<T>: they are mutable event args (Block, Cancel, MarkHandled, set Text/Message/...), run sequentially in load order, and some short-circuit.
  • Loop invariants (from pi): providers never throw, they emit Error events. An error or abort ends the run. Tool preparation runs in source order, execution runs in parallel by default, results are appended in source order. Failed assistant messages are persisted but dropped from model context. Context overflow triggers one compaction and a retry.
  • Processes: every agent-requested process gets its environment from IProcessSpawnPolicy (AgenticCoreOptions.ProcessEnvironment: deny-list, Allow, SecretValues, host hooks); AgentSession redacts SecretValues from tool output; HostProcessHardening makes the host non-dumpable on Linux, once per process. Test projects apply it with HardenHostProcess = false from a module initializer (NoHostHardening.cs).
  • Persistence: a session is a tree of JSONL entries (id/parentId + leaf pointer) under ~/.volcanic/agent/sessions/<scope>/ (VOLCANIC_AGENT_DIR overrides the root). Compaction entries replace older messages when context is built.
  • System prompt pulls project context from the working directory: AGENTS.md, or CLAUDE.md if there is no AGENTS.md (never both), plus PERSONA.md and MEMORY.md. File names match case-insensitively. SkillCatalog lists the cwd's Agent Skills (.volcanic/skills/<dir>/SKILL.md, frontmatter name/description; every part of a skill dir is optional; SkillFiles in Abstractions reads skill dirs and the shared frontmatter) in a <skills> section on every run; the model reads a SKILL.md with read. A skill dir's optional INSTRUCTIONS.md (even without SKILL.md) goes in full into <skill_instructions> on every run (16 KB/file, 64 KB total, truncation noted). Only the cwd, so chats never see each other's skills. The Claude Code provider keeps the CLI's own skills off (--setting-sources "", --disable-slash-commands).
  • Extension discovery: host code, then <cwd>/.volcanic/settings.json / .volcanic/extensions/*.dll, then the same under ~/.volcanic/agent. Earlier sources win name collisions. Each assembly loads in its own AssemblyLoadContext and shares Volcanic.AgenticCore.*, Microsoft.Extensions.* and System.* with the host.

Extensions (extensions/)

Extensions built on the public API only: providers Anthropic (Messages API), OpenAI (chat-completions compatible), ClaudeCode + ClaudeCode.Bridge; Mcp (an MCP client: stdio servers as tools, plus the Playwright browser preset; docs/mcp.md); the GoogleCalendar feature extension (calendar tool, /calendar_connect|status|disconnect, one Google account per chat keyed by session scope, tokens encrypted in the chat workspace under .volcanic/google-calendar/, watchers get a short-lived GOOGLE_ACCESS_TOKEN; docs/google-calendar.md); and the Scheduler feature extension (a process-wide JobScheduler on TimeProvider, jobs.json per chat in the workspace under .volcanic/scheduler/, the per-chat schedule tool, watcher scripts through IProcessSpawnPolicy; skill jobs are .volcanic/skills/<skill>/jobs/<job>.md files, id <skill>/<job>, mirrored into the same scheduler by SyncSkillJobsAsync (every minute, around agent runs, before /jobs//skills), never in jobs.json, run state in skill-jobs-state.json, format in the <skill_jobs> prompt section from SchedulerExtension; docs/telegram-bot.md#scheduling); and the A2A client extension (the per-session a2a tool over .volcanic/a2a/agents.json read on every call; a process-wide A2AWatchRegistry follows long remote tasks by push, subscribe or poll, persists them in .volcanic/a2a/pending.json and delivers results through IA2AResultDelivery as follow-ups, at least once; docs/a2a.md#client). The Claude Code provider is the most intricate piece (docs/claude-code-provider.md):

  • Every model call spawns a fresh claude -p with built-in tools off and replays the whole transcript as stream-json. Core tools are exposed as MCP tools mcp__volcanic__<alias> through the Bridge, a tiny MCP server that never executes tools. On tool_use the provider kills the process and returns Done(ToolUse), and the core runs the tool.
  • system/init is validated strictly (fail closed). The extension refuses to run if ANTHROPIC_API_KEY or a similar env var is set, because it is subscription-only.
  • Session expiry → ClaudeLoginCoordinator runs one login flow at a time through ClaudeCodeProviderOptions.LoginUi, then replays the paused request exactly once. /claude-code-doctor and /claude-code-login are its commands.
  • Tests replay recorded CLI output from tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures/*.jsonl.
  • Mcp: the tool catalogue is discovered once per host process (McpCatalogCache.Shared), each session starts its own server process on the first call, and tests run tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer. Mcp/Remote/RemoteMcpExtension is separate: remote servers (Streamable HTTP/SSE) per chat from the workspace's .volcanic/mcp/servers.json, headers sealed (AES-GCM, chat + visible fields as associated data) with a host secret so only the owner's /mcp_add|remove|tools|list change them; tools <server>__<tool> are registered and (de)activated in the live session; tests use an in-memory ASP.NET MCP server (Mcp/Remote/RemoteMcpTestHost).

Telegram host (hosts/Volcanic.AgenticCore.Telegram)

Long polling (Polling/, with ReconnectPolicy backoff; message, callback_query, my_chat_member). Each private chat gets its own AgentSession (Chats/ChatRegistry), with workspace <WorkspaceRoot>/<chatId> and session scope telegram-<chatId>. Access is limited by AllowedUserIds. Incoming messages go through Inbound/: the router takes a ChatInbox lease per message in update order, InboundComposer builds its part (reply/forward headers from MessageContext, text and photo) in the background, and the inbox flushes a batch InputDebounce (600 ms) after the last message as one prompt with a local-time header. Files, audio and voice are saved to <workspace>/inbox/ (voice transcribed by Speech/OpenAiSpeechToText when OPENAI_API_KEY is set; the same key gives each chat the voice tool, Chats/VoiceExtension over Speech/OpenAiTextToSpeech, which sends OGG/Opus voice notes) through InboundComposer's content steps (Inbound/InboundMedia), and Chats/TelegramChatChannel implements IChatChannel for the core send tool through ChatRenderer.RunInOrderAsync. The first allowed id is the "owner": OwnerLoginChannel sends Claude Code re-login prompts to that chat. It resolves lazily to break the DI cycle ChatRegistry → session factory → extension → ChatRegistry. Each chat's time zone lives in Chats/ChatSettingsStore (<agentDir>/telegram/chat-settings.json, default Telegram:DefaultTimeZone) and is changed through ChatTimeZoneService (/timezone, the set_timezone tool); it feeds the session's zone and the timestamp header. Scheduling/TelegramJobDelivery implements IJobDelivery (texts through the chat's IChatChannel, prompts as session follow-ups with the timestamp header, never through the inbox) and SchedulerHostedService starts the scheduler; /jobs and /cancel list and cancel a chat's jobs. Groups (Groups/, Triggers/, on only with OPENROUTER_API_KEY): the router hands group messages and my_chat_member updates to GroupFeed before the allow-list; a group is watched once an allowed user added the bot or posted there (GroupRegistry, <agentDir>/telegram/groups.json). Groups never get a session and the bot never writes there. Each private chat's trigger tool manages its topic triggers (TriggerStore, workspace .volcanic/triggers/triggers.json); TriggerEngine asks TypeSafe's Jev (JevClient, OpenRouter /systemone, a noul question per trigger, 3 retries) about every message and logs one line per message; matches are batched for 10 s by TriggerDispatcher and delivered as follow-up prompts (TelegramTriggerDelivery); snoozed triggers queue messages (SnoozeQueue) and check them as one batch when the snooze ends. /triggers lists them. RemoteMcpSetup adds RemoteMcpExtension to chat sessions (secret MCP_ENCRYPTION_SECRET, else the bot token); the router deletes a /mcp_add carrying a key and any answer to a UiRequestOptions.Secret question. ADMIN_USER_ID (Telegram:AdminUserId) may /impersonate <user_id> (Chats/Impersonation, in memory, 0 stops): the router then handles the admin's updates as the target's, and the target chat's renderer delivers to the admin's chat (ChatRenderer.DeliveryChatId); the target's own updates get Impersonation.BlockedNotice instead. Answers go out complete, not streamed (Rendering/). Tests drive the host through Support/FakeBotClient and ChatHost. A2A/ (on only with A2A__Enabled=true) mounts Volcanic.AgenticCore.A2A.Server on Kestrel (port 8080 in the container via the aspnet image's ASPNETCORE_HTTP_PORTS; under dotnet run, ASPNETCORE_URLS or Kestrel's default): TelegramA2ASetup wires a WorkspaceCallerDirectory over every allowed chat's workspace and a TelegramOwnerNotifier (notify_owner → the owner's chat, or the admin's during /impersonate); off, NoHttpServer replaces Kestrel so no port opens. The A2A client is separate (on only with A2AClient__Enabled=true): TelegramA2AClientSetup (registered before polling and before TelegramA2ASetup) adds A2AClientExtension to chat sessions only (ChatRegistry, never A2A server sessions), routes the server's OnPush into the registry and uses the server's PublicUrl as push base only while the server is on; TelegramA2AResultDelivery runs results as follow-ups through ChatFollowUps (shared with the scheduler) and A2AWatchHostedService restores watches in the background. /a2a lists a chat's callers and recent tasks, and with the client its remote agents and watches (docs/a2a.md, docs/telegram-bot.md#a2a).

Deployment

Deploy to the Coolify app volcanic-secretary-telegram (uuid r8b5tgty5v5svat9rnnojahb, builds main) through the Coolify API after pushing: POST $COOLIFY_URL/api/v1/deploy?uuid=r8b5tgty5v5svat9rnnojahb. Container setup: docs/docker.md.

Conventions

  • Commits follow Conventional Commits scoped by area: feat(claude-code): ..., fix(telegram): ..., docs(spec): ..., test(...), chore: ....
  • Design specs and implementation plans live in docs/superpowers/specs/ and docs/superpowers/plans/. Update the relevant spec and the user docs in docs/ when behaviour changes.
  • By design, the port leaves out pi's terminal UI, themes, keybindings, prompt templates, OAuth logins and session-tree navigation commands.