/ skills / bundled / scheduleRemoteAgents.ts
scheduleRemoteAgents.ts
  1  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
  2  import type { MCPServerConnection } from '../../services/mcp/types.js'
  3  import { isPolicyAllowed } from '../../services/policyLimits/index.js'
  4  import type { ToolUseContext } from '../../Tool.js'
  5  import { ASK_USER_QUESTION_TOOL_NAME } from '../../tools/AskUserQuestionTool/prompt.js'
  6  import { REMOTE_TRIGGER_TOOL_NAME } from '../../tools/RemoteTriggerTool/prompt.js'
  7  import { getClaudeAIOAuthTokens } from '../../utils/auth.js'
  8  import { checkRepoForRemoteAccess } from '../../utils/background/remote/preconditions.js'
  9  import { logForDebugging } from '../../utils/debug.js'
 10  import {
 11    detectCurrentRepositoryWithHost,
 12    parseGitRemote,
 13  } from '../../utils/detectRepository.js'
 14  import { getRemoteUrl } from '../../utils/git.js'
 15  import { jsonStringify } from '../../utils/slowOperations.js'
 16  import {
 17    createDefaultCloudEnvironment,
 18    type EnvironmentResource,
 19    fetchEnvironments,
 20  } from '../../utils/teleport/environments.js'
 21  import { registerBundledSkill } from '../bundledSkills.js'
 22  
 23  // Base58 alphabet (Bitcoin-style) used by the tagged ID system
 24  const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
 25  
 26  /**
 27   * Decode a mcpsrv_ tagged ID to a UUID string.
 28   * Tagged IDs have format: mcpsrv_01{base58(uuid.int)}
 29   * where 01 is the version prefix.
 30   *
 31   * TODO(public-ship): Before shipping publicly, the /v1/mcp_servers endpoint
 32   * should return the raw UUID directly so we don't need this client-side decoding.
 33   * The tagged ID format is an internal implementation detail that could change.
 34   */
 35  function taggedIdToUUID(taggedId: string): string | null {
 36    const prefix = 'mcpsrv_'
 37    if (!taggedId.startsWith(prefix)) {
 38      return null
 39    }
 40    const rest = taggedId.slice(prefix.length)
 41    // Skip version prefix (2 chars, always "01")
 42    const base58Data = rest.slice(2)
 43  
 44    // Decode base58 to bigint
 45    let n = 0n
 46    for (const c of base58Data) {
 47      const idx = BASE58.indexOf(c)
 48      if (idx === -1) {
 49        return null
 50      }
 51      n = n * 58n + BigInt(idx)
 52    }
 53  
 54    // Convert to UUID hex string
 55    const hex = n.toString(16).padStart(32, '0')
 56    return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
 57  }
 58  
 59  type ConnectorInfo = {
 60    uuid: string
 61    name: string
 62    url: string
 63  }
 64  
 65  function getConnectedClaudeAIConnectors(
 66    mcpClients: MCPServerConnection[],
 67  ): ConnectorInfo[] {
 68    const connectors: ConnectorInfo[] = []
 69    for (const client of mcpClients) {
 70      if (client.type !== 'connected') {
 71        continue
 72      }
 73      if (client.config.type !== 'claudeai-proxy') {
 74        continue
 75      }
 76      const uuid = taggedIdToUUID(client.config.id)
 77      if (!uuid) {
 78        continue
 79      }
 80      connectors.push({
 81        uuid,
 82        name: client.name,
 83        url: client.config.url,
 84      })
 85    }
 86    return connectors
 87  }
 88  
 89  function sanitizeConnectorName(name: string): string {
 90    return name
 91      .replace(/^claude[.\s-]ai[.\s-]/i, '')
 92      .replace(/[^a-zA-Z0-9_-]/g, '-')
 93      .replace(/-+/g, '-')
 94      .replace(/^-|-$/g, '')
 95  }
 96  
 97  function formatConnectorsInfo(connectors: ConnectorInfo[]): string {
 98    if (connectors.length === 0) {
 99      return 'No connected MCP connectors found. The user may need to connect servers at https://claude.ai/settings/connectors'
100    }
101    const lines = ['Connected connectors (available for triggers):']
102    for (const c of connectors) {
103      const safeName = sanitizeConnectorName(c.name)
104      lines.push(
105        `- ${c.name} (connector_uuid: ${c.uuid}, name: ${safeName}, url: ${c.url})`,
106      )
107    }
108    return lines.join('\n')
109  }
110  
111  const BASE_QUESTION = 'What would you like to do with scheduled remote agents?'
112  
113  /**
114   * Formats setup notes as a bulleted Heads-up block. Shared between the
115   * initial AskUserQuestion dialog text (no-args path) and the prompt-body
116   * section (args path) so notes are never silently dropped.
117   */
118  function formatSetupNotes(notes: string[]): string {
119    const items = notes.map(n => `- ${n}`).join('\n')
120    return `⚠ Heads-up:\n${items}`
121  }
122  
123  async function getCurrentRepoHttpsUrl(): Promise<string | null> {
124    const remoteUrl = await getRemoteUrl()
125    if (!remoteUrl) {
126      return null
127    }
128    const parsed = parseGitRemote(remoteUrl)
129    if (!parsed) {
130      return null
131    }
132    return `https://${parsed.host}/${parsed.owner}/${parsed.name}`
133  }
134  
135  function buildPrompt(opts: {
136    userTimezone: string
137    connectorsInfo: string
138    gitRepoUrl: string | null
139    environmentsInfo: string
140    createdEnvironment: EnvironmentResource | null
141    setupNotes: string[]
142    needsGitHubAccessReminder: boolean
143    userArgs: string
144  }): string {
145    const {
146      userTimezone,
147      connectorsInfo,
148      gitRepoUrl,
149      environmentsInfo,
150      createdEnvironment,
151      setupNotes,
152      needsGitHubAccessReminder,
153      userArgs,
154    } = opts
155    // When the user passes args, the initial AskUserQuestion dialog is skipped.
156    // Setup notes must surface in the prompt body instead, otherwise they're
157    // computed and silently discarded (regression vs. the old hard-block).
158    const setupNotesSection =
159      userArgs && setupNotes.length > 0
160        ? `\n## Setup Notes\n\n${formatSetupNotes(setupNotes)}\n`
161        : ''
162    const initialQuestion =
163      setupNotes.length > 0
164        ? `${formatSetupNotes(setupNotes)}\n\n${BASE_QUESTION}`
165        : BASE_QUESTION
166    const firstStep = userArgs
167      ? `The user has already told you what they want (see User Request at the bottom). Skip the initial question and go directly to the matching workflow.`
168      : `Your FIRST action must be a single ${ASK_USER_QUESTION_TOOL_NAME} tool call (no preamble). Use this EXACT string for the \`question\` field — do not paraphrase or shorten it:
169  
170  ${jsonStringify(initialQuestion)}
171  
172  Set \`header: "Action"\` and offer the four actions (create/list/update/run) as options. After the user picks, follow the matching workflow below.`
173  
174    return `# Schedule Remote Agents
175  
176  You are helping the user schedule, update, list, or run **remote** Claude Code agents. These are NOT local cron jobs — each trigger spawns a fully isolated remote session (CCR) in Anthropic's cloud infrastructure on a cron schedule. The agent runs in a sandboxed environment with its own git checkout, tools, and optional MCP connections.
177  
178  ## First Step
179  
180  ${firstStep}
181  ${setupNotesSection}
182  
183  ## What You Can Do
184  
185  Use the \`${REMOTE_TRIGGER_TOOL_NAME}\` tool (load it first with \`ToolSearch select:${REMOTE_TRIGGER_TOOL_NAME}\`; auth is handled in-process — do not use curl):
186  
187  - \`{action: "list"}\` — list all triggers
188  - \`{action: "get", trigger_id: "..."}\` — fetch one trigger
189  - \`{action: "create", body: {...}}\` — create a trigger
190  - \`{action: "update", trigger_id: "...", body: {...}}\` — partial update
191  - \`{action: "run", trigger_id: "..."}\` — run a trigger now
192  
193  You CANNOT delete triggers. If the user asks to delete, direct them to: https://claude.ai/code/scheduled
194  
195  ## Create body shape
196  
197  \`\`\`json
198  {
199    "name": "AGENT_NAME",
200    "cron_expression": "CRON_EXPR",
201    "enabled": true,
202    "job_config": {
203      "ccr": {
204        "environment_id": "ENVIRONMENT_ID",
205        "session_context": {
206          "model": "claude-sonnet-4-6",
207          "sources": [
208            {"git_repository": {"url": "${gitRepoUrl || 'https://github.com/ORG/REPO'}"}}
209          ],
210          "allowed_tools": ["Bash", "Read", "Write", "Edit", "Glob", "Grep"]
211        },
212        "events": [
213          {"data": {
214            "uuid": "<lowercase v4 uuid>",
215            "session_id": "",
216            "type": "user",
217            "parent_tool_use_id": null,
218            "message": {"content": "PROMPT_HERE", "role": "user"}
219          }}
220        ]
221      }
222    }
223  }
224  \`\`\`
225  
226  Generate a fresh lowercase UUID for \`events[].data.uuid\` yourself.
227  
228  ## Available MCP Connectors
229  
230  These are the user's currently connected claude.ai MCP connectors:
231  
232  ${connectorsInfo}
233  
234  When attaching connectors to a trigger, use the \`connector_uuid\` and \`name\` shown above (the name is already sanitized to only contain letters, numbers, hyphens, and underscores), and the connector's URL. The \`name\` field in \`mcp_connections\` must only contain \`[a-zA-Z0-9_-]\` — dots and spaces are NOT allowed.
235  
236  **Important:** Infer what services the agent needs from the user's description. For example, if they say "check Datadog and Slack me errors," the agent needs both Datadog and Slack connectors. Cross-reference against the list above and warn if any required service isn't connected. If a needed connector is missing, direct the user to https://claude.ai/settings/connectors to connect it first.
237  
238  ## Environments
239  
240  Every trigger requires an \`environment_id\` in the job config. This determines where the remote agent runs. Ask the user which environment to use.
241  
242  ${environmentsInfo}
243  
244  Use the \`id\` value as the \`environment_id\` in \`job_config.ccr.environment_id\`.
245  ${createdEnvironment ? `\n**Note:** A new environment \`${createdEnvironment.name}\` (id: \`${createdEnvironment.environment_id}\`) was just created for the user because they had none. Use this id for \`job_config.ccr.environment_id\` and mention the creation when you confirm the trigger config.\n` : ''}
246  
247  ## API Field Reference
248  
249  ### Create Trigger — Required Fields
250  - \`name\` (string) — A descriptive name
251  - \`cron_expression\` (string) — 5-field cron. **Minimum interval is 1 hour.**
252  - \`job_config\` (object) — Session configuration (see structure above)
253  
254  ### Create Trigger — Optional Fields
255  - \`enabled\` (boolean, default: true)
256  - \`mcp_connections\` (array) — MCP servers to attach:
257    \`\`\`json
258    [{"connector_uuid": "uuid", "name": "server-name", "url": "https://..."}]
259    \`\`\`
260  
261  ### Update Trigger — Optional Fields
262  All fields optional (partial update):
263  - \`name\`, \`cron_expression\`, \`enabled\`, \`job_config\`
264  - \`mcp_connections\` — Replace MCP connections
265  - \`clear_mcp_connections\` (boolean) — Remove all MCP connections
266  
267  ### Cron Expression Examples
268  
269  The user's local timezone is **${userTimezone}**. Cron expressions are always in UTC. When the user says a local time, convert it to UTC for the cron expression but confirm with them: "9am ${userTimezone} = Xam UTC, so the cron would be \`0 X * * 1-5\`."
270  
271  - \`0 9 * * 1-5\` — Every weekday at 9am **UTC**
272  - \`0 */2 * * *\` — Every 2 hours
273  - \`0 0 * * *\` — Daily at midnight **UTC**
274  - \`30 14 * * 1\` — Every Monday at 2:30pm **UTC**
275  - \`0 8 1 * *\` — First of every month at 8am **UTC**
276  
277  Minimum interval is 1 hour. \`*/30 * * * *\` will be rejected.
278  
279  ## Workflow
280  
281  ### CREATE a new trigger:
282  
283  1. **Understand the goal** — Ask what they want the remote agent to do. What repo(s)? What task? Remind them that the agent runs remotely — it won't have access to their local machine, local files, or local environment variables.
284  2. **Craft the prompt** — Help them write an effective agent prompt. Good prompts are:
285     - Specific about what to do and what success looks like
286     - Clear about which files/areas to focus on
287     - Explicit about what actions to take (open PRs, commit, just analyze, etc.)
288  3. **Set the schedule** — Ask when and how often. The user's timezone is ${userTimezone}. When they say a time (e.g., "every morning at 9am"), assume they mean their local time and convert to UTC for the cron expression. Always confirm the conversion: "9am ${userTimezone} = Xam UTC."
289  4. **Choose the model** — Default to \`claude-sonnet-4-6\`. Tell the user which model you're defaulting to and ask if they want a different one.
290  5. **Validate connections** — Infer what services the agent will need from the user's description. For example, if they say "check Datadog and Slack me errors," the agent needs both Datadog and Slack MCP connectors. Cross-reference with the connectors list above. If any are missing, warn the user and link them to https://claude.ai/settings/connectors to connect first.${gitRepoUrl ? ` The default git repo is already set to \`${gitRepoUrl}\`. Ask the user if this is the right repo or if they need a different one.` : ' Ask which git repos the remote agent needs cloned into its environment.'}
291  6. **Review and confirm** — Show the full configuration before creating. Let them adjust.
292  7. **Create it** \u2014 Call \`${REMOTE_TRIGGER_TOOL_NAME}\` with \`action: "create"\` and show the result. The response includes the trigger ID. Always output a link at the end: \`https://claude.ai/code/scheduled/{TRIGGER_ID}\`
293  
294  ### UPDATE a trigger:
295  
296  1. List triggers first so they can pick one
297  2. Ask what they want to change
298  3. Show current vs proposed value
299  4. Confirm and update
300  
301  ### LIST triggers:
302  
303  1. Fetch and display in a readable format
304  2. Show: name, schedule (human-readable), enabled/disabled, next run, repo(s)
305  
306  ### RUN NOW:
307  
308  1. List triggers if they haven't specified which one
309  2. Confirm which trigger
310  3. Execute and confirm
311  
312  ## Important Notes
313  
314  - These are REMOTE agents — they run in Anthropic's cloud, not on the user's machine. They cannot access local files, local services, or local environment variables.
315  - Always convert cron to human-readable when displaying
316  - Default to \`enabled: true\` unless user says otherwise
317  - Accept GitHub URLs in any format (https://github.com/org/repo, org/repo, etc.) and normalize to the full HTTPS URL (without .git suffix)
318  - The prompt is the most important part — spend time getting it right. The remote agent starts with zero context, so the prompt must be self-contained.
319  - To delete a trigger, direct users to https://claude.ai/code/scheduled
320  ${needsGitHubAccessReminder ? `- If the user's request seems to require GitHub repo access (e.g. cloning a repo, opening PRs, reading code), remind them that ${getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_lantern', false) ? "they should run /web-setup to connect their GitHub account (or install the Claude GitHub App on the repo as an alternative) — otherwise the remote agent won't be able to access it" : "they need the Claude GitHub App installed on the repo — otherwise the remote agent won't be able to access it"}.` : ''}
321  ${userArgs ? `\n## User Request\n\nThe user said: "${userArgs}"\n\nStart by understanding their intent and working through the appropriate workflow above.` : ''}`
322  }
323  
324  export function registerScheduleRemoteAgentsSkill(): void {
325    registerBundledSkill({
326      name: 'schedule',
327      description:
328        'Create, update, list, or run scheduled remote agents (triggers) that execute on a cron schedule.',
329      whenToUse:
330        'When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/triggers.',
331      userInvocable: true,
332      isEnabled: () =>
333        getFeatureValue_CACHED_MAY_BE_STALE('tengu_surreal_dali', false) &&
334        isPolicyAllowed('allow_remote_sessions'),
335      allowedTools: [REMOTE_TRIGGER_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME],
336      async getPromptForCommand(args: string, context: ToolUseContext) {
337        if (!getClaudeAIOAuthTokens()?.accessToken) {
338          return [
339            {
340              type: 'text',
341              text: 'You need to authenticate with a claude.ai account first. API accounts are not supported. Run /login, then try /schedule again.',
342            },
343          ]
344        }
345  
346        let environments: EnvironmentResource[]
347        try {
348          environments = await fetchEnvironments()
349        } catch (err) {
350          logForDebugging(`[schedule] Failed to fetch environments: ${err}`, {
351            level: 'warn',
352          })
353          return [
354            {
355              type: 'text',
356              text: "We're having trouble connecting with your remote claude.ai account to set up a scheduled task. Please try /schedule again in a few minutes.",
357            },
358          ]
359        }
360  
361        let createdEnvironment: EnvironmentResource | null = null
362        if (environments.length === 0) {
363          try {
364            createdEnvironment = await createDefaultCloudEnvironment(
365              'claude-code-default',
366            )
367            environments = [createdEnvironment]
368          } catch (err) {
369            logForDebugging(`[schedule] Failed to create environment: ${err}`, {
370              level: 'warn',
371            })
372            return [
373              {
374                type: 'text',
375                text: 'No remote environments found, and we could not create one automatically. Visit https://claude.ai/code to set one up, then run /schedule again.',
376              },
377            ]
378          }
379        }
380  
381        // Soft setup checks — collected as upfront notes embedded in the initial
382        // AskUserQuestion dialog. Never block — triggers don't require a git
383        // source (e.g., Slack-only polls), and the trigger's sources may point
384        // at a different repo than cwd anyway.
385        const setupNotes: string[] = []
386        let needsGitHubAccessReminder = false
387  
388        const repo = await detectCurrentRepositoryWithHost()
389        if (repo === null) {
390          setupNotes.push(
391            `Not in a git repo — you'll need to specify a repo URL manually (or skip repos entirely).`,
392          )
393        } else if (repo.host === 'github.com') {
394          const { hasAccess } = await checkRepoForRemoteAccess(
395            repo.owner,
396            repo.name,
397          )
398          if (!hasAccess) {
399            needsGitHubAccessReminder = true
400            const webSetupEnabled = getFeatureValue_CACHED_MAY_BE_STALE(
401              'tengu_cobalt_lantern',
402              false,
403            )
404            const msg = webSetupEnabled
405              ? `GitHub not connected for ${repo.owner}/${repo.name} \u2014 run /web-setup to sync your GitHub credentials, or install the Claude GitHub App at https://claude.ai/code/onboarding?magic=github-app-setup.`
406              : `Claude GitHub App not installed on ${repo.owner}/${repo.name} \u2014 install at https://claude.ai/code/onboarding?magic=github-app-setup if your trigger needs this repo.`
407            setupNotes.push(msg)
408          }
409        }
410        // Non-github.com hosts (GHE/GitLab/etc.): silently skip. The GitHub
411        // App check is github.com-specific, and the "not in a git repo" note
412        // would be factually wrong — getCurrentRepoHttpsUrl() below will
413        // still populate gitRepoUrl with the GHE URL.
414  
415        const connectors = getConnectedClaudeAIConnectors(
416          context.options.mcpClients,
417        )
418        if (connectors.length === 0) {
419          setupNotes.push(
420            `No MCP connectors — connect at https://claude.ai/settings/connectors if needed.`,
421          )
422        }
423  
424        const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
425        const connectorsInfo = formatConnectorsInfo(connectors)
426        const gitRepoUrl = await getCurrentRepoHttpsUrl()
427        const lines = ['Available environments:']
428        for (const env of environments) {
429          lines.push(
430            `- ${env.name} (id: ${env.environment_id}, kind: ${env.kind})`,
431          )
432        }
433        const environmentsInfo = lines.join('\n')
434        const prompt = buildPrompt({
435          userTimezone,
436          connectorsInfo,
437          gitRepoUrl,
438          environmentsInfo,
439          createdEnvironment,
440          setupNotes,
441          needsGitHubAccessReminder,
442          userArgs: args,
443        })
444        return [{ type: 'text', text: prompt }]
445      },
446    })
447  }