Remote Execution and Scheduled Triggers: The Headless AI Agent

A deep dive into Claude Code's remote capabilities — remote sessions, Server mode, Cron scheduled tasks, RemoteTrigger, and SDK headless mode

The Problem

You're on a development machine using Claude Code to fix a bug, and suddenly you need to step out. You don't want to interrupt the current task — can Claude continue working in the cloud? The next morning, you also want Claude to automatically check PR status and report back daily. This isn't science fiction — it's the core capability of Claude Code's remote execution system.

Traditional CLI tools are bound to terminal sessions — close the terminal, and the process dies. Claude Code breaks this limitation by introducing remote capabilities across three dimensions:

  1. Remote Sessions — Run Claude in a cloud environment, with the local terminal acting only as a proxy
  2. Server Mode (Direct Connect) — Connect to a remote Claude instance via WebSocket
  3. Scheduled Triggers (Cron + RemoteTrigger) — Let Claude execute tasks automatically on a schedule

This article provides a deep analysis of the design and implementation across all three dimensions.


Remote Session Architecture Overview

Local Terminal
Claude CLI
Precondition Checks
Bridge / Teleport
Cloud (CCR)
Remote Environment
Repository Clone
Headless Claude Agent
Tool Execution
Result Stream
Scheduling System
CronCreateTool
Scheduler
Scheduled Trigger
RemoteTriggerTool
claude.ai API
Remote Agent

Remote Session Preconditions

Remote sessions aren't unconditionally available. The system performs a series of eligibility checks before creating a remote session to ensure the environment meets requirements. These checks are defined in src/utils/background/remote/preconditions.ts:

src/utils/background/remote/preconditions.ts
TypeScript
1export async function checkNeedsClaudeAiLogin(): Promise<boolean> {
2 if (!isClaudeAISubscriber()) {
3 return false
4 }
5 return checkAndRefreshOAuthTokenIfNeeded()
6}

The precondition checks use a parallel execution strategy, coordinated in remoteSession.ts:

src/utils/background/remote/remoteSession.ts
TypeScript
1const [needsLogin, hasRemoteEnv, repository] = await Promise.all([
2 checkNeedsClaudeAiLogin(),
3 checkHasRemoteEnvironment(),
4 detectCurrentRepositoryWithHost(),
5])

This code demonstrates an important design pattern — parallel precondition checks. Three independent checks fire simultaneously:

  1. Login status — Whether the OAuth token is valid
  2. Remote environment — Whether the user has an available cloud environment
  3. Repository detection — Whether the current directory is within a Git repository, along with remote information

Bundle Seed Mechanism

An interesting optimization is the Bundle Seed mechanism. When enabled (via the CCR_FORCE_BUNDLE or CCR_ENABLE_BUNDLE environment variables), the system only needs a local .git/ directory — no GitHub remote and no GitHub App required:

src/utils/background/remote/remoteSession.ts
TypeScript
1const bundleSeedGateOn =
2 !skipBundle &&
3 (isEnvTruthy(process.env.CCR_FORCE_BUNDLE) ||
4 isEnvTruthy(process.env.CCR_ENABLE_BUNDLE) ||
5 (await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bundle_seed_enabled')))
6
7if (!checkIsInGitRepo()) {
8 errors.push({ type: 'not_in_git_repo' })
9} else if (bundleSeedGateOn) {
10 // has .git/, bundle will work — skip remote+app checks
11}

This means for local repositories (a git init repo without a GitHub remote), Bundle Seed can package and upload local code to the cloud instead of requiring CCR (Claude Code Remote) to pull from GitHub.

Repository Access Layers

For scenarios that require pulling code from GitHub, the system implements layered access checking:

src/utils/background/remote/preconditions.ts
TypeScript
1export async function checkRepoForRemoteAccess(
2 owner: string,
3 repo: string,
4): Promise<{ hasAccess: boolean; method: RepoAccessMethod }> {
5 if (await checkGithubAppInstalled(owner, repo)) {
6 return { hasAccess: true, method: 'github-app' }
7 }
8 if (
9 getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_lantern', false) &&
10 (await checkGithubTokenSynced())
11 ) {
12 return { hasAccess: true, method: 'token-sync' }
13 }
14 return { hasAccess: false, method: 'none' }
15}

Three priority levels:

  1. GitHub App — The preferred method, authorized through a GitHub App installed on the repository
  2. Token Sync — A GitHub token synced via /web-setup (gated by a feature flag)
  3. None — The user needs to configure an access method

Remote Session Type System

Remote sessions have explicit type definitions and a state machine:

src/utils/background/remote/remoteSession.ts
TypeScript
1export type BackgroundRemoteSession = {
2 id: string
3 command: string
4 startTime: number
5 status: 'starting' | 'running' | 'completed' | 'failed' | 'killed'
6 todoList: TodoList
7 title: string
8 type: 'remote_session'
9 log: SDKMessage[]
10}
...

Sessions record a complete message log via SDKMessage[], meaning that even if the local connection drops, the full execution history can be restored upon reconnecting.


Direct Connect: Server Mode

Server mode is an alternative remote execution method — rather than creating a new environment via Teleport in the cloud, it connects to a server already running Claude Code. This is particularly useful in enterprise intranet scenarios.

Session Creation

src/server/createDirectConnectSession.ts
TypeScript
1export async function createDirectConnectSession({
2 serverUrl,
3 authToken,
4 cwd,
5 dangerouslySkipPermissions,
6}: {
7 serverUrl: string
8 authToken?: string
9 cwd: string
10 dangerouslySkipPermissions?: boolean
11}): Promise<{
12 config: DirectConnectConfig
13 workDir?: string
14}> {

The creation process sends a POST request to ${serverUrl}/sessions, and the returned DirectConnectConfig contains key information:

src/server/directConnectManager.ts
TypeScript
1export type DirectConnectConfig = {
2 serverUrl: string
3 sessionId: string
4 wsUrl: string
5 authToken?: string
6}

WebSocket Bidirectional Communication

DirectConnectSessionManager encapsulates the complete WebSocket communication protocol:

src/server/directConnectManager.ts
TypeScript
1export class DirectConnectSessionManager {
2 private ws: WebSocket | null = null
3 private config: DirectConnectConfig
4 private callbacks: DirectConnectCallbacks
5
6 constructor(config: DirectConnectConfig, callbacks: DirectConnectCallbacks) {
7 this.config = config
8 this.callbacks = callbacks
9 }

Message handling splits into three channels:

  1. SDK messages — Standard messages like assistant/result/system, forwarded to the local UI
  2. Permission requestscontrol_request type messages requiring local user confirmation
  3. Filtered messages — Internal messages like keep_alive and streamlined_text, not forwarded
src/server/directConnectManager.ts
TypeScript
1if (
2 parsed.type !== 'control_response' &&
3 parsed.type !== 'keep_alive' &&
4 parsed.type !== 'control_cancel_request' &&
5 parsed.type !== 'streamlined_text' &&
6 parsed.type !== 'streamlined_tool_use_summary' &&
7 !(parsed.type === 'system' && parsed.subtype === 'post_turn_summary')
8) {
9 this.callbacks.onMessage(parsed)
10}

Permission Requests and Interrupts

Permission handling during remote execution is particularly critical. When the remote Agent needs to perform a dangerous operation, the permission request is sent to the local machine via WebSocket. After the user makes a decision, the result is sent back:

src/server/directConnectManager.ts
TypeScript
1respondToPermissionRequest(
2 requestId: string,
3 result: RemotePermissionResponse,
4): void {
5 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
6 return
7 }
8 const response = jsonStringify({
9 type: 'control_response',
10 response: {
11 subtype: 'success',
12 request_id: requestId,
13 response: {
14 behavior: result.behavior,
15 ...(result.behavior === 'allow'
16 ? { updatedInput: result.updatedInput }
17 : { message: result.message }),
18 },
19 },
20 })
21 this.ws.send(response)
22}

The interrupt mechanism also works through WebSocket:

src/server/directConnectManager.ts
TypeScript
1sendInterrupt(): void {
2 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
3 return
4 }
5 const request = jsonStringify({
6 type: 'control_request',
7 request_id: crypto.randomUUID(),
8 request: {
9 subtype: 'interrupt',
10 },
11 })
12 this.ws.send(request)
13}

Cron Scheduled Task System

Claude Code includes a complete Cron scheduling system that lets the AI Agent execute tasks automatically on a schedule.

Task Storage

src/utils/cronTasks.ts
TypeScript
1export type CronTask = {
2 id: string
3 /** 5-field cron string (local time) */
4 cron: string
5 /** Prompt to enqueue when the task fires. */
6 prompt: string
7 /** Epoch ms when the task was created. */
8 createdAt: number
9 /** Epoch ms of the most recent fire. */
10 lastFiredAt?: number
11 /** When true, the task reschedules after firing. */
12 recurring?: boolean
13 /** When true, exempt from recurringMaxAgeMs auto-expiry. */
14 permanent?: boolean
15 /** Runtime-only flag. false → session-scoped. */
16 durable?: boolean
17 /** Runtime-only. Created by an in-process teammate. */
18 agentId?: string
19}

Tasks are stored in two ways:

TypeStorage LocationLifecycleUse Case
Durable.claude/scheduled_tasks.jsonPersists across sessionsCreated by users via CronCreateTool
Session-onlyIn-process memory (bootstrap/state)Dies with the processTemporary tasks created by sub-agents

Scheduler Lock

When multiple Claude sessions run in the same project directory, only one should drive the Cron scheduler. The system uses file locks for coordination:

src/utils/cronTasksLock.ts
TypeScript
1export async function tryAcquireSchedulerLock(
2 opts?: SchedulerLockOptions,
3): Promise<boolean> {
4 const dir = opts?.dir
5 const sessionId = opts?.lockIdentity ?? getSessionId()
6 const lock: SchedulerLock = {
7 sessionId,
8 pid: process.pid,
9 acquiredAt: Date.now(),
10 }
11
12 if (await tryCreateExclusive(lock, dir)) {
13 lastBlockedBy = undefined
14 registerLockCleanup(opts)
15 return true
16 }
17
18 const existing = await readLock(dir)
19 // Already ours (idempotent)
20 if (existing?.sessionId === sessionId) {
21 if (existing.pid !== process.pid) {
22 await writeFile(getLockPath(dir), jsonStringify(lock))
23 registerLockCleanup(opts)
24 }
25 return true
26 }
27
28 // Another live session — blocked
29 if (existing && isProcessRunning(existing.pid)) {
30 return false
31 }
32
33 // Stale — unlink and retry
34 await unlink(getLockPath(dir)).catch(() => {})
35 if (await tryCreateExclusive(lock, dir)) {
36 return true
37 }
38 return false
39}

The lock design has several subtle features:

  1. O_EXCL atomic creation — Uses the 'wx' flag to ensure lock file creation is atomic
  2. PID liveness detection — Uses isProcessRunning() to check whether the lock-holding process is still alive
  3. Stale lock recovery — If the lock-holding process has died, deletes the lock file and retries
  4. Idempotent reacquisition — If the session ID matches (PID changed after --resume), updates the PID
...

Jitter to Prevent Thundering Herd

When many users set the same cron expression (e.g., 0 * * * *, every hour on the hour), all tasks fire simultaneously, causing inference service load spikes. The system uses a Jitter mechanism to spread out trigger times.

Forward jitter for recurring tasks:

src/utils/cronTasks.ts
TypeScript
1export function jitteredNextCronRunMs(
2 cron: string,
3 fromMs: number,
4 taskId: string,
5 cfg: CronJitterConfig = DEFAULT_CRON_JITTER_CONFIG,
6): number | null {
7 const t1 = nextCronRunMs(cron, fromMs)
8 if (t1 === null) return null
9 const t2 = nextCronRunMs(cron, t1)
10 if (t2 === null) return t1
11 const jitter = Math.min(
12 jitterFrac(taskId) * cfg.recurringFrac * (t2 - t1),
13 cfg.recurringCapMs,
14 )
15 return t1 + jitter
16}

Jitter is calculated based on a hash of the task ID, ensuring determinism (the same task always gets the same delay) and uniform distribution. With default configuration, recurring tasks with a one-hour interval are spread between :00 and :06.

Backward jitter for one-shot tasks:

One-shot tasks (like "remind me at 3 PM") can't be delayed — that would violate user expectations. But firing slightly early is acceptable:

src/utils/cronTasks.ts
TypeScript
1export function oneShotJitteredNextCronRunMs(
2 cron: string,
3 fromMs: number,
4 taskId: string,
5 cfg: CronJitterConfig = DEFAULT_CRON_JITTER_CONFIG,
6): number | null {
7 const t1 = nextCronRunMs(cron, fromMs)
8 if (t1 === null) return null
9 if (new Date(t1).getMinutes() % cfg.oneShotMinuteMod !== 0) return t1
10 const lead =
11 cfg.oneShotFloorMs +
12 jitterFrac(taskId) * (cfg.oneShotMaxMs - cfg.oneShotFloorMs)
13 return Math.max(t1 - lead, fromMs)
14}

Jitter is only applied at the top and bottom of the hour (:00 and :30) — because humans tend to choose these "round" times. The default maximum is 90 seconds early.

Jitter Configuration as Operational Knobs

src/utils/cronTasks.ts
TypeScript
1export const DEFAULT_CRON_JITTER_CONFIG: CronJitterConfig = {
2 recurringFrac: 0.1,
3 recurringCapMs: 15 * 60 * 1000, // 15-minute cap
4 oneShotMaxMs: 90 * 1000, // 90-second max lead
5 oneShotFloorMs: 0,
6 oneShotMinuteMod: 30, // Jitter only at :00 and :30
7 recurringMaxAgeMs: 7 * 24 * 60 * 60 * 1000, // 7-day auto-expiry
8}

These configurations can be remotely adjusted via GrowthBook's tengu_kairos_cron_config. When the inference service experiences capacity issues, operations can push more aggressive configurations — for example, changing oneShotMinuteMod to 15 (covering :00/:15/:30/:45) and oneShotMaxMs to 300000 (5 minutes) to significantly spread the load.

Recurring Task Auto-Expiry

Recurring tasks have a default 7-day lifetime, preventing forgotten tasks from consuming resources indefinitely:

TypeScript
1// cronTasks.ts comment
2// Recurring tasks auto-expire this many ms after creation (unless marked
3// permanent). Cron is the primary driver of multi-day sessions (p99
4// uptime 61min → 53h post-#19931), and unbounded recurrence lets Tier-1
5// heap leaks compound indefinitely.

Only tasks marked as permanent (such as the built-in catch-up/morning-checkin/dream tasks in assistant mode) are exempt from expiry.


RemoteTrigger Tool

RemoteTriggerTool is another remote execution path — instead of using the local scheduler, it directly calls the claude.ai API to manage remote triggers:

src/tools/RemoteTriggerTool/prompt.ts
TypeScript
1export const PROMPT = `Call the claude.ai remote-trigger API.
2Use this instead of curl — the OAuth token is added automatically
3in-process and never exposed.
4
5Actions:
6- list: GET /v1/code/triggers
7- get: GET /v1/code/triggers/{trigger_id}
8- create: POST /v1/code/triggers (requires body)
9- update: POST /v1/code/triggers/{trigger_id} (requires body, partial update)
10- run: POST /v1/code/triggers/{trigger_id}/run`

The core security principle is that the OAuth token is never exposed in the shell. The tool adds the authentication header directly in-process, preventing the token from appearing in command-line arguments or environment variables where it could be captured by other processes or log systems.


SDK Headless Mode

The lowest layer of remote execution is the SDK's headless mode. When launched with --input-format stream-json, Claude Code doesn't start a terminal UI — instead, it communicates via JSON streams over stdin/stdout.

The Direct Connect message format must match SDK expectations:

src/server/directConnectManager.ts
TypeScript
1const message = jsonStringify({
2 type: 'user',
3 message: {
4 role: 'user',
5 content: content,
6 },
7 parent_tool_use_id: null,
8 session_id: '',
9})

This format is fully consistent with SDKUserMessage, ensuring unified message handling whether the instance is a local REPL or a remote headless instance.


Security Model

Remote execution introduces additional security considerations:

Policy Layer
allow_remote_sessions Policy
Authentication Layer
OAuth Token
GitHub App / Token Sync
Permission Layer
Tool Permission Requests
dangerouslySkipPermissions
Isolation Layer
Independent Cloud Environment
Sandboxed Execution
  1. Policy controlisPolicyAllowed('allow_remote_sessions') intercepts at the outermost layer
  2. OAuth authentication — Ensures the user's identity is legitimate
  3. Repository access — Layered checks for GitHub App or Token Sync
  4. Permission proxying — The remote Agent's tool usage requires local user confirmation via WebSocket
  5. Environment isolation — Each remote session runs in an independent environment

The dangerouslySkipPermissions option is intended only for controlled environments (such as CI/CD). It bypasses permission interactions but does not bypass security policies.


Complete Data Flow

The data flow for a complete remote execution request is as follows:

  1. The user initiates a remote task locally
  2. The system checks preconditions in parallel (login, environment, repository)
  3. A connection is established via Teleport/Direct Connect
  4. The remote Agent receives the prompt and begins execution
  5. Tool permission requests are sent back to the local machine via WebSocket
  6. After user confirmation, the permission response is sent back to the remote
  7. Execution results stream back via SDK messages
  8. The task completes and status updates to completed

For Cron tasks, the trigger flow is slightly different:

  1. The scheduler acquires the lock (ensuring single-instance execution)
  2. Checks .claude/scheduled_tasks.json for due tasks
  3. Calculates the actual trigger time with jitter applied
  4. Enqueues the prompt into the message queue
  5. The main REPL loop (or sub-agent) processes tasks from the queue
  6. Recurring tasks update lastFiredAt and reschedule

Summary

Claude Code's remote execution system demonstrates how to extend a terminal tool into a distributed AI Agent platform. The core design principles include:

  • Multi-path remote execution — Teleport (new cloud environment), Direct Connect (connect to existing server), and RemoteTrigger (API-triggered) are three independent paths covering different scenarios
  • Deterministic jitter — Hash-based deterministic delays tied to task IDs prevent thundering herd effects while maintaining predictability
  • File lock coordination — O_EXCL atomic creation + PID liveness detection resolves multi-session scheduler contention
  • Layered security — Policy, authentication, permissions, and isolation form four layers of protection, ensuring remote execution doesn't weaken security guarantees
  • Operational controllability — Jitter configuration and task expiry policies can be adjusted in real time via remote configuration

These designs weren't built in a vacuum — they solve the engineering challenges that inevitably arise when an AI Agent evolves from a single-machine tool into a distributed system.