- Published on
Inside Clawdbot: Sessions, Sub-Agents, and Multi-Agent Orchestration
- Authors

- Name
- Avasdream
- @avasdream_
This is the fifth post in my Clawdbot deep dive series. Previous posts covered core architecture, memory, agent system, and channels & messaging. Here I'm looking at how Clawdbot manages sessions — the isolation boundary that keeps every conversation, cron job, and spawned sub-agent in its own context — and how the multi-agent system lets sessions talk to each other.
Sessions are the backbone of Clawdbot's concurrency model. Every conversation context — a DM, a group chat, a Telegram topic, a cron job, a background sub-agent — gets its own session with its own state, transcript, model overrides, and send policy. The source code spans session stores, key resolution, sub-agent registries, and cross-session communication across ~15K lines.
Session Keys: The Hierarchical Namespace
Every session is identified by a colon-delimited key that encodes its entire routing context — which agent owns it, what channel it came from, what type of conversation it is.
The canonical form is always agent-prefixed:
agent:{agentId}:{rest}
Where {rest} encodes the session type. Here's the full taxonomy:
| Session Type | Key Pattern | Example |
|---|---|---|
| Main (DM) | agent:{agentId}:main | agent:main:main |
| Group | agent:{agentId}:{channel}:group:{groupId} | agent:main:telegram:group:12345 |
| Channel | agent:{agentId}:{channel}:channel:{channelId} | agent:main:discord:channel:98765 |
| Thread/Topic | ...base...:thread:{threadId} | agent:main:telegram:group:12345:topic:789 |
| Sub-agent | agent:{agentId}:subagent:{uuid} | agent:main:subagent:abc-def-123 |
| DM per-peer | agent:{agentId}:dm:{peerId} | agent:main:dm:user123 |
| DM per-channel-peer | agent:{agentId}:{channel}:dm:{peerId} | agent:main:telegram:dm:user123 |
| Cron | cron:{jobId} | cron:daily-email-check |
| Hook | hook:{hookId} | hook:github-push |
The key structure is not decorative — it's functional. The system derives channel, chat type, and thread parentage directly from the key by parsing colon-separated segments. A key like agent:main:telegram:group:12345:topic:789 tells you the agent (main), the channel (telegram), the chat type (group), the group ID (12345), and the topic thread (789) without any additional lookups.
Key Building
Session keys are constructed through dedicated builder functions rather than string concatenation:
// routing/session-key.ts
export function buildAgentPeerSessionKey(params: {
agentId: string;
channel: string;
peerKind?: "dm" | "group" | "channel";
peerId?: string;
dmScope?: "main" | "per-peer" | "per-channel-peer";
}): string {
// DM sessions collapse to main unless dmScope is per-peer/per-channel-peer
// Group/channel sessions always isolated by channel+peerId
}
export function resolveThreadSessionKeys(params: {
baseSessionKey: string;
threadId?: string;
}): { sessionKey: string; parentSessionKey?: string } {
// Appends :thread:{threadId} suffix when threadId is present
}
The main Alias
The word main is a configurable alias, not a fixed key. It resolves based on the agent's session scope:
export function resolveMainSessionAlias(cfg: ClawdbotConfig) {
const mainKey = normalizeMainKey(cfg.session?.mainKey); // defaults to "main"
const scope = cfg.session?.scope ?? "per-sender";
const alias = scope === "global" ? "global" : mainKey;
return { mainKey, alias, scope };
}
In per-sender scope (the default), each user gets their own main session. In global scope, everyone shares one. The alias system means main always resolves to the right key for display purposes, even when the underlying key is global.
DM Scope and Peer Isolation
DM sessions support three isolation modes configured via dmScope:
main(default): All DMs collapse into the single main session. One conversation history regardless of which channel the user messages from.per-peer: Each peer gets their own session (agent:main:dm:user123). Different users have different histories.per-channel-peer: Each peer on each channel gets their own session (agent:main:telegram:dm:user123). The same user on Telegram and Discord gets separate conversations.
For multi-channel setups, identityLinks can merge peers across channels:
// Config: link telegram:123 and discord:456 to the same identity
{
session: {
identityLinks: {
"alice": ["telegram:123", "discord:456"]
}
}
}
Both peers resolve to agent:main:dm:alice, sharing one session.
Gateway Resolution
The gateway's sessions.resolve endpoint supports three mutually exclusive lookup strategies:
- By key: Direct store lookup with canonicalization
- By sessionId: UUID search across the full store
- By label: Human-readable label lookup with uniqueness enforcement
Each must return exactly one match. Ambiguous label lookups fail explicitly rather than guessing.
Session Lifecycle
Creation
Sessions are created lazily. The first time a message arrives for a session key, the store creates a SessionEntry with a fresh UUID:
const next: SessionEntry = existing
? { ...existing, updatedAt: Math.max(existing.updatedAt ?? 0, now) }
: { sessionId: randomUUID(), updatedAt: now };
No pre-provisioning, no registration step. Send a message to a session key and it exists.
The SessionEntry
Each session carries rich metadata:
export type SessionEntry = {
sessionId: string; // UUID identifying the session
updatedAt: number; // Last activity timestamp (ms)
sessionFile?: string; // Path to JSONL transcript file
spawnedBy?: string; // Parent session key (sub-agents)
label?: string; // Human-readable label
// Model & thinking overrides
providerOverride?: string;
modelOverride?: string;
thinkingLevel?: string; // off|low|medium|high|xhigh
// Execution environment overrides
execHost?: string; // sandbox|gateway|node
execSecurity?: string; // deny|allowlist|full
// Delivery context
channel?: string;
chatType?: SessionChatType; // group|channel|dm
sendPolicy?: "allow" | "deny";
groupActivation?: "mention" | "always";
// Token tracking
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
compactionCount?: number;
// Queue management
queueMode?: "steer" | "followup" | "collect";
queueDebounceMs?: number;
// ... and more
};
Every session can override its model, thinking level, execution environment, and send policy independently. A Telegram group can run Opus with high thinking while a Discord group runs Sonnet with thinking off.
Labels
Labels are human-friendly identifiers — max 64 characters, unique within a store. They're the primary way agents and users refer to sessions by name rather than by cryptic key. Label uniqueness is enforced at write time:
if ("label" in patch) {
for (const [key, entry] of Object.entries(store)) {
if (key === storeKey) continue;
if (entry?.label === parsed.label) {
return invalid(`label already in use: ${parsed.label}`);
}
}
next.label = parsed.label;
}
Session Reset (Freshness)
Sessions auto-reset based on two modes:
daily: Resets at a configured hour (default 4:00 AM). The session clears its history and starts fresh.idle: Resets after N minutes of inactivity (default 60).
Reset policies can be configured per session type (DM, group, thread) and per channel:
{
session: {
reset: {
mode: "idle",
idleMinutes: 120, // Default: 2 hours
overrides: {
group: { mode: "daily", hour: 4 },
thread: { mode: "idle", idleMinutes: 30 }
}
}
}
}
The reset type is derived from the session key itself — :group: or :channel: in the key means it's a group, :thread: or :topic: means it's a thread, everything else is a DM.
File-Based Persistence
Store Architecture
Sessions are stored as flat JSON files, one per agent:
~/.clawdbot/agents/{agentId}/sessions/
├── sessions.json ← session store (all SessionEntry objects)
├── {sessionId-1}.jsonl ← transcript for session 1
├── {sessionId-2}.jsonl ← transcript for session 2
└── {sessionId-3}-topic-789.jsonl ← topic-specific transcript
The store path supports {agentId} templates, so multi-agent setups get isolated directories automatically.
Store Locking & Caching
The session store implements file-level locking with stale-lock eviction and an in-memory cache with a 45-second TTL:
// All mutations go through a file lock
export async function updateSessionStore<T>(
storePath: string,
mutator: (store: Record<string, SessionEntry>) => Promise<T> | T,
): Promise<T> {
return await withSessionStoreLock(storePath, async () => {
const store = loadSessionStore(storePath, { skipCache: true });
const result = await mutator(store);
await saveSessionStoreUnlocked(storePath, store);
return result;
});
}
The lock uses wx file creation (atomic create-or-fail) with stale eviction after 30 seconds and 25ms polling. Writes use atomic rename (write to tmp, rename to target) on non-Windows with 0o600 permissions.
This is a deliberate design choice: no database, no ORM, no migration headaches. The store is a JSON file you can cat and jq. Transcripts are JSONL you can grep.
Transcript Storage
Each session's conversation history lives in a JSONL file — one JSON object per line. The file starts with a header:
{
"type": "session",
"version": 1,
"id": "abc-def-123",
"timestamp": "2026-01-28T10:00:00.000Z",
"cwd": "/home/vincent/clawd"
}
Subsequent lines are messages: user inputs, assistant responses, tool calls and results. Outbound messages get mirrored into the transcript as delivery records:
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: mirrorText }],
provider: "clawdbot",
model: "delivery-mirror",
usage: { input: 0, output: 0, totalTokens: 0 },
});
This means the transcript is a complete record of both what the model generated and what was actually delivered to the user.
Transcript Events (Pub/Sub)
A simple in-process pub/sub system fires on transcript changes:
const SESSION_TRANSCRIPT_LISTENERS = new Set<SessionTranscriptListener>();
export function emitSessionTranscriptUpdate(sessionFile: string): void {
for (const listener of SESSION_TRANSCRIPT_LISTENERS) {
listener({ sessionFile });
}
}
This enables real-time features — webchat streaming, session monitoring, and the TUI's live transcript view all subscribe to these events.
Sub-Agent Spawning
Sub-agent spawning is the mechanism by which a running agent creates isolated child sessions for background work. The parent continues its conversation while the child runs asynchronously, and results are announced back when it finishes.
The Flow
┌─────────────────────────────────────────────────────────┐
│ Spawn Flow │
│ │
│ User: "Research X in the background" │
│ │ │
│ ▼ │
│ Main Agent ──sessions_spawn──► Gateway │
│ │ │ │
│ │ "On it, working ▼ │
│ │ in background" Create child session │
│ │ agent:main:subagent:{uuid} │
│ │ │ │
│ │ ▼ │
│ │ Run child agent │
│ │ (isolated transcript, │
│ │ own model/thinking) │
│ │ │ │
│ │ ▼ │
│ │ Child completes │
│ │ │ │
│ │ lifecycle event │ │
│ │ ◄────────────────────────┘ │
│ │ │
│ ▼ │
│ Announce flow: summarize results to user's chat │
└─────────────────────────────────────────────────────────┘
Tool Schema
const SessionsSpawnToolSchema = Type.Object({
task: Type.String(), // Task description
label: Type.Optional(Type.String()), // Human-readable label
agentId: Type.Optional(Type.String()), // Cross-agent spawning
model: Type.Optional(Type.String()), // Model override
thinking: Type.Optional(Type.String()), // Thinking level override
runTimeoutSeconds: Type.Optional(Type.Number()),
cleanup: optionalStringEnum(["delete", "keep"]),
});
Spawn Steps
Validate requester: Sub-agents cannot spawn further sub-agents. This is a hard constraint, not a suggestion:
if (isSubagentSessionKey(requesterSessionKey)) { return jsonResult({ status: "forbidden", error: "sessions_spawn is not allowed from sub-agent sessions", }); }No recursion, no runaway chains. One level deep, period.
Resolve target agent: Defaults to the requester's agent. Cross-agent spawning requires explicit
allowAgentsconfig:const allowAgents = resolveAgentConfig(cfg, requesterAgentId) ?.subagents?.allowAgents ?? [];Generate child session key:
agent:{targetAgentId}:subagent:{uuid}Apply model override: Resolved from the tool parameter, per-agent config, or global default — in that priority order.
Build system prompt: The child gets a system prompt that constrains its behavior:
export function buildSubagentSystemPrompt(params) { return [ "# Subagent Context", "You are a **subagent** spawned by the main agent.", `- You were created to handle: ${taskText}`, "- Complete this task. That's your entire purpose.", "## Rules", "1. Stay focused - Do your assigned task, nothing else", "2. Complete the task - Your final message will be reported", "3. Don't initiate - No heartbeats, no proactive actions", `- Requester session: ${requesterSessionKey}`, `- Your session: ${childSessionKey}`, ].join("\\n"); }Dispatch: The child run starts asynchronously on the
AGENT_LANE_SUBAGENTlane, separate from the main processing pipeline.Register in sub-agent registry: Track the run for lifecycle management and crash recovery.
The Sub-Agent Registry
The registry is an in-memory Map<string, SubagentRunRecord> that persists to ~/.clawdbot/subagents/runs.json:
export type SubagentRunRecord = {
runId: string;
childSessionKey: string;
requesterSessionKey: string;
task: string;
cleanup: "delete" | "keep";
label?: string;
createdAt: number;
startedAt?: number;
endedAt?: number;
outcome?: SubagentRunOutcome; // ok|error|timeout|unknown
archiveAtMs?: number;
};
Two parallel mechanisms track lifecycle:
- In-process listener:
onAgentEvent()catches lifecycle events from embedded runs - Cross-process wait:
agent.waitRPC to the gateway for runs in other processes
On process restart, the registry restores from disk, resumes pending announcements for completed runs, and re-issues agent.wait for ones still running.
Announce Flow
When a sub-agent completes, results are announced back to the requester:
export async function runSubagentAnnounceFlow(params) {
// 1. Read the sub-agent's last assistant reply
// 2. Build stats line (runtime, tokens, cost)
// 3. Compose trigger message for the requester agent:
const triggerMessage = [
`A background task "${taskLabel}" just ${statusLabel}.`,
"Findings:", reply || "(no output)",
statsLine,
"Summarize this naturally for the user.",
].join("\\n");
// 4. Queue-aware delivery to requester session
}
The announce is queue-aware. If the requester's session is actively running, the announcement can be:
- Steered: Injected into the active run's message queue (immediate integration)
- Queued: Buffered until the current run completes (no interruption)
- Direct: Sent immediately if no run is active
This prevents sub-agent results from clobbering an ongoing conversation.
Cleanup
Two modes:
keep: Session and transcript preserved. Auto-archived after a configurable timeout, then swept by a 60-second periodic cleaner.delete: Session and transcript deleted immediately after announcement.
Cross-Session Communication
sessions_send Tool
The primary mechanism for inter-session messaging. An agent can send a message to any session it has access to:
const SessionsSendToolSchema = Type.Object({
sessionKey: Type.Optional(Type.String()),
label: Type.Optional(Type.String()),
agentId: Type.Optional(Type.String()),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Number()),
});
Target resolution: by sessionKey, label, or label + agentId. Sandboxed sessions can only see sessions they spawned — they can't reach into arbitrary conversations.
Agent-to-Agent (A2A) Policy
Cross-agent communication requires explicit opt-in:
export function createAgentToAgentPolicy(cfg: ClawdbotConfig): AgentToAgentPolicy {
const enabled = cfg.tools?.agentToAgent?.enabled === true;
const allowPatterns = cfg.tools?.agentToAgent?.allow ?? [];
const isAllowed = (requester, target) => {
if (requester === target) return true; // Same-agent always OK
if (!enabled) return false;
return matchesAllow(requester) && matchesAllow(target);
};
}
Same-agent sends always work. Cross-agent sends require agentToAgent.enabled: true and both agents matching the allow patterns (which support wildcards).
A2A Ping-Pong
After an initial send, agents can engage in multi-turn back-and-forth:
┌──────────────────────────────────────────────────────┐
│ A2A Ping-Pong Flow │
│ │
│ Agent A ──send──► Agent B │
│ Agent A ◄──reply── Agent B │
│ Agent A ──reply──► Agent B │
│ Agent A ◄──reply── Agent B │
│ ...up to 5 turns (DEFAULT_PING_PONG_TURNS)... │
│ │
│ Agent B ──announce──► Channel (final summary) │
└──────────────────────────────────────────────────────┘
Each turn runs as a nested agent step on AGENT_LANE_NESTED. Either agent can terminate early by returning a REPLY_SKIP sentinel. The target agent posts the final summary to its channel (unless it sends ANNOUNCE_SKIP).
for (let turn = 1; turn <= maxPingPongTurns; turn++) {
const replyText = await runAgentStep({
sessionKey: currentSessionKey,
message: incomingMessage,
extraSystemPrompt: replyPrompt,
lane: AGENT_LANE_NESTED,
});
if (!replyText || isReplySkip(replyText)) break;
// Swap roles
[currentSessionKey, nextSessionKey] = [nextSessionKey, currentSessionKey];
}
This is bounded — max 5 turns by default, capped at 5. No infinite agent loops.
Model and Level Overrides
Every session can override its model, thinking level, and execution environment independently of the global defaults. Overrides are applied via sessions.patch:
// Set a session to use Opus with high thinking
{
method: "sessions.patch",
params: {
key: "agent:main:telegram:group:12345",
model: "anthropic/claude-opus-4-5",
thinkingLevel: "high"
}
}
Patchable Fields
| Field | Values | Notes |
|---|---|---|
model | Any allowed model ref | Validated against catalog + allowlist |
thinkingLevel | off, low, medium, high, xhigh | xhigh auto-downgrades if model doesn't support it |
verboseLevel | on, off | |
reasoningLevel | on, off, stream | |
sendPolicy | allow, deny | |
groupActivation | mention, always | |
execHost | sandbox, gateway, node | |
execSecurity | deny, allowlist, full | |
label | Any string ≤64 chars | Must be unique |
spawnedBy | Session key | Write-once, subagent sessions only |
The xhigh thinking level has a guard — if you change the model to one that doesn't support extended thinking, the level silently downgrades to high:
if (next.thinkingLevel === "xhigh") {
if (!supportsXHighThinking(effectiveProvider, effectiveModel)) {
next.thinkingLevel = "high"; // Silent downgrade on model change
}
}
If you explicitly set thinkingLevel: "xhigh" on an unsupported model, you get an error. But if you change the model and the existing level becomes invalid, it degrades gracefully.
Send Policies and Delivery Routing
Send policies control whether the agent is allowed to send messages in a given session. Three levels, evaluated in order:
┌─────────────────────────────────────────┐
│ 1. Per-session override (wins always) │
│ entry.sendPolicy = "deny" │
├─────────────────────────────────────────┤
│ 2. Rule-based matching │
│ Match on: channel, chatType, │
│ keyPrefix. First deny wins. │
├─────────────────────────────────────────┤
│ 3. Global default │
│ cfg.session.sendPolicy.default │
│ Falls back to "allow" │
└─────────────────────────────────────────┘
Rules can match on channel, chat type, or key prefix:
{
session: {
sendPolicy: {
default: "allow",
rules: [
// Deny all Discord group sends
{ action: "deny", match: { channel: "discord", chatType: "group" } },
// Deny cron sessions
{ action: "deny", match: { keyPrefix: "cron:" } },
]
}
}
}
The system derives channel and chat type from the session key when they're not explicitly stored — agent:main:telegram:group:12345 gives you channel: "telegram" and chatType: "group" without needing those fields populated in the entry.
Session Isolation and Security
The Isolation Model
Sessions are the primary security boundary. Each session has its own:
- Transcript: No cross-reading without explicit
sessions_historycalls - Model/thinking level: Compromising one session's model config doesn't affect others
- Execution environment: A session can be sandboxed while others run on the host
- Send policy: Per-session deny prevents a compromised session from spamming channels
Sub-Agent Isolation
Sub-agents are maximally constrained:
- No recursion: Sub-agents cannot spawn further sub-agents
- Immutable parentage:
spawnedByis write-once — a session can't be re-parented after creation - Scoped visibility: Sandboxed agents can only see sessions they spawned
- Separate lanes: Sub-agents run on
AGENT_LANE_SUBAGENT, preventing them from blocking the main agent
if ("spawnedBy" in patch) {
if (existing?.spawnedBy && existing.spawnedBy !== trimmed) {
return invalid("spawnedBy cannot be changed once set");
}
if (!isSubagentSessionKey(storeKey)) {
return invalid("spawnedBy is only supported for subagent:* sessions");
}
}
Cross-Agent Guards
- A2A disabled by default:
tools.agentToAgent.enabledmust be explicitlytrue - Allow patterns: Both the requester and target agent must match the pattern list
- Cross-agent spawn allowlist:
subagents.allowAgents[]explicitly lists which agents can be spawned cross-agent
Messaging Classification
The system classifies tool calls as messaging actions for policy enforcement:
const CORE_MESSAGING_TOOLS = new Set(["sessions_send", "message"]);
export function isMessagingTool(toolName: string): boolean {
if (CORE_MESSAGING_TOOLS.has(toolName)) return true;
const providerId = normalizeChannelId(toolName);
return Boolean(providerId && getChannelPlugin(providerId)?.actions);
}
This means send policies apply not just to built-in tools but to any channel plugin with messaging capabilities.
Architecture Summary
┌─────────────────────────────────────────────────────────┐
│ Session Store │
│ ~/.clawdbot/agents/{agentId}/sessions/sessions.json │
│ │
│ Keys: │
│ ├── agent:main:main (DM main session) │
│ ├── agent:main:telegram:group:123 (group chat) │
│ ├── agent:main:subagent:uuid (spawned child) │
│ ├── agent:beta:main (different agent) │
│ ├── cron:daily-check (cron job) │
│ └── hook:github (webhook) │
├──────────────────────────┬──────────────────────────────┤
│ SessionEntry │ Transcript (.jsonl) │
│ - sessionId (UUID) │ - Session header │
│ - model overrides │ - user/assistant messages │
│ - thinking level │ - tool calls & results │
│ - send policy │ - delivery mirrors │
│ - delivery context │ │
│ - token tracking │ │
│ - spawnedBy │ │
└──────────────────────────┴──────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Access Control │
│ │
│ 1. Send Policy per-session → rules → global default │
│ 2. A2A Policy tools.agentToAgent.enabled + allow[] │
│ 3. Sandbox Scope spawned-only for sandboxed agents │
│ 4. Spawn Guard subagents.allowAgents[] for cross-agent│
│ 5. Parentage spawnedBy is write-once, immutable │
│ 6. No Recursion sub-agents cannot spawn sub-agents │
└─────────────────────────────────────────────────────────┘
Key Design Decisions
File-based persistence: No database. JSON + JSONL files with file locks. You can inspect session state with
catandjq. Portable, debuggable, zero dependencies.Hierarchical key namespace: Colon-delimited keys encode all routing context. No separate routing table — the key is the route.
Lazy creation: Sessions materialize on first use. No provisioning, no schema migrations.
No sub-agent recursion: Hard constraint, not a configurable limit. Prevents runaway spawning regardless of prompt injection.
Queue-aware delivery: Sub-agent results integrate with the message queue to avoid interrupting active conversations.
Crash recovery: Sub-agent registry persists to disk. Process restart resumes pending announcements and re-attaches to running agents.
Immutable parentage:
spawnedByis write-once. A session cannot be re-parented, preventing adoption attacks.
What's Next
The next post covers Clawdbot's CLI, Commands, and TUI — the command system, the terminal UI, and how they connect to the gateway.
Series
- Core Architecture & Gateway
- How Clawdbot Remembers: Memory System
- Agent System & AI Providers
- Channel System & Messaging
- Sessions & Multi-Agent (this post)
- CLI, Commands & TUI
- Browser, Media & Canvas
- Infrastructure & Security