import { ApiRequestError, apiRequest, apiRequestRaw, authHeaders, getApiBaseUrl, getAppBaseUrl, loadFreshApiKey } from './common.ts' import { buildAgentTestInputs, getAgentHistory } from './agent-test-cases.ts' // ============================================================================ // Types // ============================================================================ export type AgentHarness = 'claude' | 'codex' export type AgentApiVersion = 'v4' | 'v5' export interface AgentInputVariable { id?: string name: string type: 'file' | 'text' required: boolean description?: string } export interface Agent { id: string workspaceId: string projectId: string organizationName: string repository: string title: string description: string | null isFavorite: boolean | null isMultiturn: boolean | null inputSchema: AgentInputVariable[] allowedCredentials: string[] | null templateScope: 'workspace' | 'global' | null publishedId: string | null publishedAt: string | null publishedBy: string | null createdBy: string createdAt: string updatedAt: string deletedAt: string | null } export interface AgentConfig { apiVersion?: AgentApiVersion harness?: AgentHarness model?: string maxRounds?: number isMultiturn?: boolean autoCompact?: boolean workflowTool?: boolean thinking?: boolean [key: string]: unknown } export interface AgentRepositoryTree { tree: Array<{ path: string, type: 'blob' | 'tree', sha: string, size?: number }> branch: string commitHash: string } export interface AgentFileContent { content: string encoding: 'raw' | 'base64' branch: string commitHash: string filePath: string } export interface AgentCommitResult { success: boolean branch: string commitHash: string } export interface DeleteAgentPathResult extends AgentCommitResult { type: 'file' | 'directory' deletedPath: string deletedFiles?: string[] } export interface RenameAgentPathResult extends AgentCommitResult { type: 'file' | 'directory' oldPath: string newPath: string movedFiles?: Array<{ from: string, to: string }> } export interface UpdateAgentModelResult extends AgentCommitResult { organizationName: string repository: string model: string providerTemplate: string templateSynced: boolean files: string[] deletedFiles: string[] } export interface UpdateAgentHarnessResult extends AgentCommitResult { organizationName: string repository: string harness: AgentHarness previousHarness?: AgentHarness converted: boolean files: string[] deletedFiles: string[] } export interface AddAgentSkillResult extends AgentCommitResult { skill: { name: string description?: string path: string files: string[] } } export interface AddAgentCapabilityResult { success: boolean branch?: string commitHash?: string capability?: { kind: 'canvas' | 'workflow' | 'agent' sourceId: string sourceVersionId: string name: string description?: string slug: string skillPath: string files: string[] } error?: string } export interface CreateAgentSubagentResult { subagentName: string filePath: string file: AgentCommitResult } export interface UploadAgentFilesResult { success: boolean branch?: string commitHash?: string files?: Array<{ filePath: string, size: number }> error?: string } export interface RestoreAgentVersionResult extends AgentCommitResult { restoredFrom: string changesApplied: { created: string[], modified: string[], deleted: string[] } } export interface PublishAgentResult { success: boolean publishedId: string publishedAt: string } export interface CreateAgentInputResult { input: AgentInputVariable commitHash: string } export interface AgentTemplate { id: string organizationName: string repository: string scope: 'workspace' | 'global' name: string description?: string allowedCredentials: string[] userCredentials: string[] tags: string[] } export interface AgentSkillMetadata { name: string description?: string [key: string]: unknown } /** A named input value for an agent execution (same shape as test case inputs) */ export type AgentExecutionInput = | { type: 'text', name: string, content: string } | { type: 'file', name: string, vaultRef: string, filename: string, metadata?: string } export interface ExecuteAgentPayload { /** User message (required, ≤800k chars). Text inputs are injected into instructions. */ message: string /** Continue an existing session (multiturn) instead of starting a new one */ sessionId?: string /** Named input variables — build with buildAgentExecutionInputs */ inputSchema?: AgentExecutionInput[] /** Extra file attachments (not bound to a declared input) */ attachments?: Array<{ vaultRef: string, filename: string }> environmentVariables?: Record /** Branch name or commitHash. testAgent defaults to main; runAgent to publishedId */ ref?: string apiVersion?: AgentApiVersion harness?: AgentHarness model?: string isMultiturn?: boolean recover?: boolean } export type RunAgentPayload = Omit & { webhooks?: Array<{ url: string events?: string[] secret?: string }> } export interface ExecuteAgentResult { success: boolean sessionId: string runId?: string status?: 'accepted' harness?: string streamUrl?: string resultUrl?: string } export type AgentSessionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'waiting_messages' | 'cancelled' export interface AgentSessionThreadTurn { runId: string index: number isContinuation: boolean status: string | null createdAt: number | null finishedAt: number | null user: { text: string | null, attachments?: unknown } | null assistant: { status: string | null text: string | null structuredContent?: unknown events?: unknown[] | null attachments?: unknown usage?: unknown } | null } export interface AgentSessionThread { sessionId: string status: string turnCount: number turns: AgentSessionThreadTurn[] } export interface AgentSessionTimeline { sessionId: string status: AgentSessionStatus metrics: Record prompt: { model: string | null, claudeSessionId: string | null, timestamp: number | null, text: string | null } events: unknown[] spans: unknown[] } export interface AgentSessionFiles { sessionId: string tree: Array<{ path: string, size: number }> truncated: boolean capturedAt: number } export interface AgentSessionFileContent { filePath: string content: string encoding: 'raw' | 'base64' size: number } // ============================================================================ // Payloads & Options // ============================================================================ export interface CreateAgentPayload { title: string projectId: string description?: string /** Clone from a promoted agent template (see listAgentTemplates) */ templateId?: string } export interface UpdateAgentPayload { title?: string description?: string projectId?: string isFavorite?: boolean isMultiturn?: boolean testReviewerSystemPrompt?: string | null testReviewerContextFiles?: Array<{ vaultRef: string, filename: string }> | null } export interface AgentFileOptions { branch?: string commitHash?: string } export interface AgentCommitOptions { branch?: string commitMessage?: string } export type CreateAgentInputPayload = Omit export type UpdateAgentInputPayload = Partial export interface AddAgentCapabilityPayload { kind: 'canvas' | 'workflow' | 'agent' sourceId: string branch?: string name?: string description?: string overwrite?: boolean commitMessage?: string } // ============================================================================ // Internal helpers // ============================================================================ // Agent endpoints use `vaultRef` fields that must NOT be rewritten by // parsePayload's vault:// → { file_url } expansion, so every body is // pre-serialized with JSON.stringify (string bodies skip parsePayload). function jsonBody(payload: unknown): string { return JSON.stringify(payload) } // File paths travel in the /files/* wildcard segment — each segment must be // URL-encoded (the API decodes with decodeURIComponent). function encodeFilePath(filePath: string): string { return filePath.split('/').map(encodeURIComponent).join('/') } function buildFileQuery(options?: AgentFileOptions & { branch?: string }): string { const params = new URLSearchParams() if (options?.branch) params.set('branch', options.branch) if (options && 'commitHash' in options && options.commitHash) params.set('commitHash', options.commitHash) const qs = params.toString() return qs ? `?${qs}` : '' } // Canonicalizes a repo path (resolves '.'/'..' segments) so the managed-file // guards match aliases like 'subdir/../CLAUDE.md'; escaping the repo root is // refused (the API rejects it too — this just fails with a clearer error) function normalizePath(filePath: string): string { const segments: string[] = [] for (const segment of filePath.replace(/\\/g, '/').split('/')) { if (!segment || segment === '.') { continue } if (segment === '..') { if (segments.length === 0) { throw new Error(`Invalid file path "${filePath}": paths cannot escape the repository root`) } segments.pop() continue } segments.push(segment) } return segments.join('/') } /** * Managed files may not be written through putAgentFile — each has a dedicated * function that owns its validation and consistency semantics. The instruction * files are shared with the agent template: only the `# Purpose … ---END---` * block belongs to the agent (what the Tela UI shows as "instructions"); * everything else is template territory and gets overwritten on template syncs. */ const MANAGED_FILE_HANDLERS: Record = { 'CLAUDE.md': 'tela.updateAgentPurpose', 'AGENTS.md': 'tela.updateAgentPurpose', 'agent.config.json': 'tela.updateAgentConfig (or updateAgentModel/updateAgentHarness)', 'output-format.json': 'tela.updateAgentOutputSchema', '.claude/settings.json': 'nothing — it is system-managed and must not be edited', } const INSTRUCTION_FILES = new Set(['CLAUDE.md', 'AGENTS.md']) /** Files the agent runtime requires — deleting/renaming them breaks the agent */ const PROTECTED_AGENT_FILES = new Set([ 'CLAUDE.md', 'AGENTS.md', 'agent.config.json', 'output-format.json', '.claude/settings.json', ]) // A path is off-limits when it IS a protected file or is a directory that // contains one (deleting/renaming '.claude' would take settings.json with it) function touchesProtectedFile(path: string): boolean { if (PROTECTED_AGENT_FILES.has(path)) { return true } return path === '' || [...PROTECTED_AGENT_FILES].some(file => file.startsWith(`${path}/`)) } export const AGENT_INPUT_NAME_REGEX = /^[a-zA-Z0-9_-]+$/ // The runtime mounts reference material at input/references — the name is // reserved and the API rejects it const RESERVED_AGENT_INPUT_NAMES = new Set(['references']) function assertValidAgentInputName(name: string): void { if (!AGENT_INPUT_NAME_REGEX.test(name)) { throw new Error(`Invalid input name "${name}": must match ${AGENT_INPUT_NAME_REGEX}`) } if (RESERVED_AGENT_INPUT_NAMES.has(name)) { throw new Error(`Input name "${name}" is reserved`) } } // Skill and subagent names are interpolated into repo paths — the same safe // charset as input names keeps them from escaping their directory function assertValidAgentResourceName(name: string, kind: 'skill' | 'subagent'): void { if (!AGENT_INPUT_NAME_REGEX.test(name)) { throw new Error(`Invalid ${kind} name "${name}": must match ${AGENT_INPUT_NAME_REGEX}`) } } // ============================================================================ // Agent CRUD // ============================================================================ /** * List agents in the workspace, optionally filtered by project */ export async function listAgents(options?: { projectId?: string }): Promise { const params = new URLSearchParams() if (options?.projectId) params.set('projectId', options.projectId) const qs = params.toString() const { data } = await apiRequest<{ data: Agent[] }>(`/agent${qs ? `?${qs}` : ''}`) return data } /** * Get an agent by ID (input variables come synced with the repository) */ export async function getAgent(agentId: string): Promise { const { data } = await apiRequest<{ data: Agent }>(`/agent/${agentId}`) return data } /** * Create a new agent. This provisions the agent's git repository from the * default template (or from templateId). Iterate afterwards via * updateAgentPurpose / createAgentInput / putAgentFile — never by cloning. * The endpoint envelope nests the record ({ agent, repository, template? }); * only the agent record is returned — the git repository stays abstracted. */ export async function createAgent(payload: CreateAgentPayload): Promise { const { data } = await apiRequest<{ data: { agent: Agent, template?: AgentTemplate } }>('/agent', { method: 'POST', body: jsonBody(payload), }) return data.agent } /** * Update agent metadata (title, description, project, isMultiturn, ...) */ export async function updateAgent(agentId: string, payload: UpdateAgentPayload): Promise { const { data } = await apiRequest<{ data: Agent }>(`/agent/${agentId}`, { method: 'PATCH', body: jsonBody(payload), }) return data } /** * Delete an agent (soft delete) */ export async function deleteAgent(agentId: string): Promise { const response = await apiRequestRaw(`/agent/${agentId}`, { method: 'DELETE' }) if (!response.ok) { throw new ApiRequestError(response.status, `API request failed: ${response.status} ${response.statusText} - ${await response.text()}`) } } /** * List agent templates available for createAgent({ templateId }) */ export async function listAgentTemplates(): Promise { const { data } = await apiRequest<{ data: { templates: AgentTemplate[] } }>('/agent/templates') return data.templates } // ============================================================================ // Purpose (agent instructions) // ============================================================================ /** * Read the agent's instructions — the `# Purpose … ---END---` block inside * CLAUDE.md (or AGENTS.md on codex). This is what the Tela UI shows. */ export async function getAgentPurpose( agentId: string, options?: AgentFileOptions, ): Promise { const { data } = await apiRequest<{ data: AgentFileContent }>( `/agent/${agentId}/purpose${buildFileQuery(options)}`, ) return data } /** * Update the agent's instructions. This is THE way to change agent behavior: * it rewrites only the Purpose block, is shown in the Tela UI, and survives * template syncs. The content must not contain the `# Purpose` heading or the * `---END---` marker — pass only the instructions themselves. */ export async function updateAgentPurpose( agentId: string, content: string, options?: AgentCommitOptions, ): Promise { const query = options?.branch ? `?branch=${encodeURIComponent(options.branch)}` : '' const { data } = await apiRequest<{ data: AgentCommitResult }>( `/agent/${agentId}/purpose${query}`, { method: 'PUT', body: jsonBody({ content, commitMessage: options?.commitMessage }) }, ) return data } // ============================================================================ // Repository files // ============================================================================ /** * Get the agent repository file tree (defaults to main HEAD) */ export async function getAgentFileTree( agentId: string, options?: AgentFileOptions, ): Promise { const { data } = await apiRequest<{ data: AgentRepositoryTree }>( `/agent/${agentId}/files${buildFileQuery(options)}`, ) return data } /** * Read a file from the agent repository (any file, including CLAUDE.md) */ export async function getAgentFile( agentId: string, filePath: string, options?: AgentFileOptions, ): Promise { const { data } = await apiRequest<{ data: AgentFileContent }>( `/agent/${agentId}/files/${encodeFilePath(normalizePath(filePath))}${buildFileQuery(options)}`, ) return data } // Internal writer without the managed-file guard — used by the dedicated // functions (updateAgentConfig, updateAgentOutputSchema) that own those files async function putAgentFileUnchecked( agentId: string, path: string, content: string, options?: AgentCommitOptions, ): Promise { const query = options?.branch ? `?branch=${encodeURIComponent(options.branch)}` : '' const { data } = await apiRequest<{ data: AgentCommitResult }>( `/agent/${agentId}/files/${encodeFilePath(path)}${query}`, { method: 'PUT', body: jsonBody({ content, commitMessage: options?.commitMessage }) }, ) return data } /** * Create or update a file in the agent repository (creates a commit). * Refuses managed files — CLAUDE.md/AGENTS.md (instructions live in the * Purpose block; anything outside it is invisible in the Tela UI and gets * discarded when the agent template re-syncs), agent.config.json, * output-format.json, and .claude/settings.json — each has a dedicated * function that owns its validation and consistency semantics. */ export async function putAgentFile( agentId: string, filePath: string, content: string, options?: AgentCommitOptions, ): Promise { const path = normalizePath(filePath) if (INSTRUCTION_FILES.has(path)) { throw new Error( `Refusing to write ${path} directly: only the Purpose block belongs to the agent — ` + 'the rest is owned by the agent template and is overwritten on template syncs. ' + 'Use tela.updateAgentPurpose(agentId, content) instead.', ) } const handler = MANAGED_FILE_HANDLERS[path] if (handler) { throw new Error(`Refusing to write ${path} directly: it is a managed file. Use ${handler} instead.`) } return await putAgentFileUnchecked(agentId, path, content, options) } /** * Delete a file or directory from the agent repository. * Refuses to delete files the agent runtime requires — directly or by * deleting a directory that contains one. */ export async function deleteAgentFile( agentId: string, filePath: string, options?: AgentCommitOptions, ): Promise { const path = normalizePath(filePath) if (touchesProtectedFile(path)) { throw new Error(`Refusing to delete ${path}: the agent runtime requires it (or a file inside it) and deleting it breaks the agent.`) } const query = options?.branch ? `?branch=${encodeURIComponent(options.branch)}` : '' const { data } = await apiRequest<{ data: DeleteAgentPathResult }>( `/agent/${agentId}/files/${encodeFilePath(path)}${query}`, { method: 'DELETE', body: jsonBody({ commitMessage: options?.commitMessage }) }, ) return data } /** * Rename or move a file/directory in the agent repository. * Refuses to move files the agent runtime requires — in either direction * (as the source path or as the destination being overwritten). */ export async function renameAgentFile( agentId: string, filePath: string, newPath: string, options?: AgentCommitOptions, ): Promise { const path = normalizePath(filePath) if (touchesProtectedFile(path)) { throw new Error(`Refusing to move ${path}: the agent runtime requires it (or a file inside it) at its exact path.`) } const destination = normalizePath(newPath) if (touchesProtectedFile(destination)) { throw new Error(`Refusing to move onto ${destination}: overwriting it breaks the agent.`) } const query = options?.branch ? `?branch=${encodeURIComponent(options.branch)}` : '' const { data } = await apiRequest<{ data: RenameAgentPathResult }>( `/agent/${agentId}/files/${encodeFilePath(path)}${query}`, { method: 'PATCH', body: jsonBody({ newPath, commitMessage: options?.commitMessage }) }, ) return data } /** * Upload binary/large files to the agent repository (multipart, one commit). * files: [{ path: 'references/logo.png', content: Blob | string }] * Refuses managed/runtime-required paths — those go through their dedicated * functions (updateAgentPurpose, updateAgentConfig, updateAgentOutputSchema). */ export async function uploadAgentFiles( agentId: string, files: Array<{ path: string, content: Blob | string }>, options?: AgentCommitOptions, ): Promise { for (const { path } of files) { const normalized = normalizePath(path) if (INSTRUCTION_FILES.has(normalized) || PROTECTED_AGENT_FILES.has(normalized)) { throw new Error( `Refusing to upload over ${normalized}: it is a managed file — use ` + 'tela.updateAgentPurpose / tela.updateAgentConfig / tela.updateAgentOutputSchema instead.', ) } } const form = new FormData() for (const { path, content } of files) { const blob = typeof content === 'string' ? new Blob([content]) : content const name = path.split('/').pop() ?? path form.append('files[]', blob, name) form.append('paths[]', path) } if (options?.commitMessage) form.append('commitMessage', options.commitMessage) const query = options?.branch ? `?branch=${encodeURIComponent(options.branch)}` : '' // apiRequestRaw JSON-serializes non-string bodies and forces a JSON // Content-Type, so multipart goes through fetch directly (fetch sets the // multipart boundary itself when no Content-Type is given) const response = await fetch(`${getApiBaseUrl()}/agent/${agentId}/upload${query}`, { method: 'POST', body: form, headers: authHeaders(await loadFreshApiKey()), }) if (!response.ok) { throw new ApiRequestError(response.status, `API request failed: ${response.status} ${response.statusText} - ${await response.text()}`) } const { data } = await response.json() as { data: UploadAgentFilesResult } return data } // ============================================================================ // Config (model / harness / agent.config.json) // ============================================================================ /** * Read and parse the agent's agent.config.json */ export async function getAgentConfig(agentId: string, options?: AgentFileOptions): Promise { const file = await getAgentFile(agentId, 'agent.config.json', options) return JSON.parse(file.content) as AgentConfig } /** * Change the agent's model. Also handles provider-template syncing server-side * (templateSynced: true means CLAUDE.md/settings were rewritten from template, * preserving only the Purpose block). */ export async function updateAgentModel( agentId: string, model: string, options?: { commitMessage?: string }, ): Promise { const { data } = await apiRequest<{ data: UpdateAgentModelResult }>(`/agent/${agentId}/model`, { method: 'PUT', body: jsonBody({ model, commitMessage: options?.commitMessage }), }) return data } /** * Switch the agent harness ('claude' | 'codex'). Converts the whole repo * layout (CLAUDE.md ↔ AGENTS.md, .claude/ ↔ .agents/.codex/) server-side. */ export async function updateAgentHarness( agentId: string, harness: AgentHarness, options?: { commitMessage?: string }, ): Promise { const { data } = await apiRequest<{ data: UpdateAgentHarnessResult }>(`/agent/${agentId}/harness`, { method: 'PUT', body: jsonBody({ harness, commitMessage: options?.commitMessage }), }) return data } // updateAgentConfig is a read-merge-write; interleaved calls for the same // agent would clobber each other's fields, so writes are serialized per agent const agentConfigWriteQueues = new Map>() function withAgentConfigLock(agentId: string, task: () => Promise): Promise { const previous = agentConfigWriteQueues.get(agentId) ?? Promise.resolve() const next = previous.then(task, task) agentConfigWriteQueues.set(agentId, next.catch(() => undefined)) return next } /** * After a config write, look for external commits that landed between the * commit we read (baseCommitHash) and the commit we created (writtenHash). * 'clean' — nothing interleaved (or the interleaved commits left the config * untouched). 'overwritten' — our write clobbered an external config change; * the caller must re-merge externalContent. 'unknown' — the history or * historical-file lookup failed, so whether the write was safe cannot be * established; the caller must NOT treat this as clean. */ type ConfigConflictScan = | { outcome: 'clean' } | { outcome: 'overwritten', externalContent: string } | { outcome: 'unknown', reason: string } async function detectOverwrittenConfig( agentId: string, branch: string | undefined, baseCommitHash: string, baseContent: string, writtenHash: string, ): Promise { try { const history = await getAgentHistory(agentId, { branch, limit: 20 }) const hashes = history.commits.map(commit => commit.commitHash) const writtenIdx = hashes.indexOf(writtenHash) const baseIdx = hashes.indexOf(baseCommitHash) if (writtenIdx === -1 || baseIdx === -1) { return { outcome: 'unknown', reason: 'the written or base commit was not found in recent history' } } if (baseIdx <= writtenIdx + 1) { return { outcome: 'clean' } // no commits landed in between } const newestExternal = history.commits[writtenIdx + 1]! const externalFile = await getAgentFile(agentId, 'agent.config.json', { commitHash: newestExternal.commitHash }) return externalFile.content !== baseContent ? { outcome: 'overwritten', externalContent: externalFile.content } : { outcome: 'clean' } } catch (error) { return { outcome: 'unknown', reason: error instanceof Error ? error.message : String(error) } } } /** * Merge changes into agent.config.json (isMultiturn, maxRounds, thinking, ...). * model/harness are rejected — those have dedicated endpoints that also keep * templates and repo layout in sync (updateAgentModel / updateAgentHarness). * * The API has no conditional-write primitive, so lost updates are handled * client-side in three layers: calls for the same agent are serialized * in-process; the optional expectedCommitHash precondition refuses writes on * a stale read; and after every write (including repair writes) the commit * history is checked for an external config change that landed since the * previous read/write — when one is found, it is re-merged on top of that * newest external snapshot (our fields still win) and written again, until a * write lands with nothing interleaved. Sustained interleaving beyond a few * rounds throws instead of silently dropping another writer's fields, and a * conflict check that cannot run (history lookup failure) also throws — a * failed check is never taken as evidence that the write was safe. */ const MAX_CONFIG_REPAIR_ROUNDS = 3 export async function updateAgentConfig( agentId: string, partial: Partial, options?: AgentCommitOptions & { expectedCommitHash?: string }, ): Promise { if ('model' in partial || 'harness' in partial) { throw new Error('Use tela.updateAgentModel / tela.updateAgentHarness for model/harness — they keep templates and repo layout in sync.') } return await withAgentConfigLock(agentId, async () => { const currentFile = await getAgentFile(agentId, 'agent.config.json', { branch: options?.branch }) if (options?.expectedCommitHash && options.expectedCommitHash !== currentFile.commitHash) { throw new Error( `agent.config.json changed since it was read (expected commit ${options.expectedCommitHash}, ` + `found ${currentFile.commitHash}) — re-read the config and retry`, ) } const merged = { ...JSON.parse(currentFile.content) as AgentConfig, ...partial } let baseHash = currentFile.commitHash let baseContent = currentFile.content let writtenContent = `${JSON.stringify(merged, null, 4)}\n` let written = await putAgentFileUnchecked(agentId, 'agent.config.json', writtenContent, { branch: options?.branch, commitMessage: options?.commitMessage, }) // Repair the lost-update race: an external writer may have committed // between our read and our write, in which case we just overwrote it. // A repair write can itself race a further external commit, so // detection re-runs after every write, each round re-merging on top of // the newest external snapshot, until a write lands with nothing // interleaved for (let round = 0; round < MAX_CONFIG_REPAIR_ROUNDS; round++) { let scan = await detectOverwrittenConfig(agentId, options?.branch, baseHash, baseContent, written.commitHash) if (scan.outcome === 'unknown') { // history reads can fail transiently — retry once before giving up scan = await detectOverwrittenConfig(agentId, options?.branch, baseHash, baseContent, written.commitHash) } if (scan.outcome === 'unknown') { throw new Error( `agent.config.json update for agent ${agentId} was committed as ${written.commitHash}, but the ` + `post-write conflict check failed (${scan.reason}) — a concurrent external update may have ` + 'been overwritten. Re-read with tela.getAgentConfig and verify before retrying.', ) } if (scan.outcome === 'clean') { return written } const repaired = { ...JSON.parse(scan.externalContent) as AgentConfig, ...partial } baseHash = written.commitHash baseContent = writtenContent writtenContent = `${JSON.stringify(repaired, null, 4)}\n` written = await putAgentFileUnchecked(agentId, 'agent.config.json', writtenContent, { branch: options?.branch, commitMessage: options?.commitMessage ?? 'Merge concurrent agent.config.json update', }) } throw new Error( `agent.config.json update for agent ${agentId} was written, but external writers kept committing ` + `concurrently through ${MAX_CONFIG_REPAIR_ROUNDS} repair rounds — the latest write may be missing ` + 'their fields. Re-read with tela.getAgentConfig and reconcile manually.', ) }) } // ============================================================================ // Output format // ============================================================================ /** * Read the agent's output schema (output-format.json). * Empty object → the agent returns markdown text; non-empty → structured JSON. */ export async function getAgentOutputSchema( agentId: string, options?: AgentFileOptions, ): Promise> { const file = await getAgentFile(agentId, 'output-format.json', options) try { return JSON.parse(file.content) as Record } catch { return {} } } /** * Write the agent's output schema (commits output-format.json). * Top-level shape is attributeName -> { type, description, items?, properties? } * (a flat attribute map, NOT a JSON Schema wrapper). Pass {} to switch the * agent back to markdown output. Attribute paths defined here are what test * case attribute feedback and custom test metrics reference. */ export async function updateAgentOutputSchema( agentId: string, schema: Record, options?: AgentCommitOptions, ): Promise { if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) { throw new Error('Output schema must be a plain object (attributeName -> { type, ... }); pass {} for markdown output') } return await putAgentFileUnchecked(agentId, 'output-format.json', `${JSON.stringify(schema, null, 2)}\n`, { ...options, commitMessage: options?.commitMessage ?? 'Update output format', }) } // ============================================================================ // Input variables // ============================================================================ /** * List the agent's declared input variables (synced with the repository) */ export async function listAgentInputs(agentId: string): Promise { const agent = await getAgent(agentId) return agent.inputSchema ?? [] } /** * Declare an input variable on the agent. * name must match /^[a-zA-Z0-9_-]+$/. At runtime, text inputs are injected * into the instructions; file inputs are mounted under input//. */ export async function createAgentInput( agentId: string, payload: CreateAgentInputPayload, ): Promise { assertValidAgentInputName(payload.name) const { data } = await apiRequest<{ data: CreateAgentInputResult }>(`/agent/${agentId}/input`, { method: 'POST', body: jsonBody(payload), }) return data } /** * Update a declared input variable */ export async function updateAgentInput( agentId: string, inputId: string, payload: UpdateAgentInputPayload, ): Promise { if (payload.name !== undefined) { assertValidAgentInputName(payload.name) } const { data } = await apiRequest<{ data: CreateAgentInputResult }>( `/agent/${agentId}/input/${inputId}`, { method: 'PATCH', body: jsonBody(payload) }, ) return data } /** * Remove a declared input variable */ export async function deleteAgentInput(agentId: string, inputId: string): Promise { await apiRequest(`/agent/${agentId}/input/${inputId}`, { method: 'DELETE' }) } // ============================================================================ // Skills, subagents & capabilities // ============================================================================ /** * Preview a skill's metadata before adding it (ref: gh:owner/repo/path[@branch]) */ export async function discoverAgentSkill(ref: string): Promise { const { data } = await apiRequest<{ data: AgentSkillMetadata }>('/agent/skills/discover', { method: 'POST', body: jsonBody({ ref }), }) return data } /** * List the public skills store (curated skills installable by ref) */ export async function listAgentSkillsStore(options?: { refresh?: boolean }): Promise { const query = options?.refresh ? '?refresh=true' : '' const { data } = await apiRequest<{ data: { items: unknown[] } }>(`/agent/skills/store${query}`) return data.items } /** * Install a skill from GitHub into the agent (.claude/skills//). * Skills are the right place for reusable behavior/instructions beyond the * Purpose — they survive template syncs and show up in the Tela UI tree. * Duplicate skill names are rejected with 409. */ export async function addAgentSkill( agentId: string, ref: string, options?: AgentCommitOptions, ): Promise { const query = options?.branch ? `?branch=${encodeURIComponent(options.branch)}` : '' const { data } = await apiRequest<{ data: AddAgentSkillResult }>( `/agent/${agentId}/skills${query}`, { method: 'POST', body: jsonBody({ ref, commitMessage: options?.commitMessage }) }, ) return data } /** * Create a custom skill directly in the agent repo at * .claude/skills//SKILL.md — for behavior that doesn't come from GitHub. * The SKILL.md needs YAML frontmatter with name and description. */ export async function createAgentCustomSkill( agentId: string, name: string, skillMd: string, options?: AgentCommitOptions, ): Promise { assertValidAgentResourceName(name, 'skill') return await putAgentFile(agentId, `.claude/skills/${name}/SKILL.md`, skillMd, { ...options, commitMessage: options?.commitMessage ?? `Add ${name} skill`, }) } /** * Create a subagent scaffold at .claude/agents/.md * (name: /^[a-zA-Z0-9-_]+$/). Fill it in with updateAgentSubagent. */ export async function createAgentSubagent( agentId: string, name: string, ): Promise { assertValidAgentResourceName(name, 'subagent') const { data } = await apiRequest<{ data: CreateAgentSubagentResult }>( `/agent/${agentId}/subagents`, { method: 'POST', body: jsonBody({ name }) }, ) return data } /** * Write a subagent's full content (.claude/agents/.md). * Content needs YAML frontmatter: name, description, tools, model. */ export async function updateAgentSubagent( agentId: string, name: string, content: string, options?: AgentCommitOptions, ): Promise { assertValidAgentResourceName(name, 'subagent') return await putAgentFile(agentId, `.claude/agents/${name}.md`, content, { ...options, commitMessage: options?.commitMessage ?? `Update ${name} subagent`, }) } /** * Add another Tela resource (published canvas, workflow, or agent) as a tool * the agent can call — generated as a skill in the repo. */ export async function addAgentCapability( agentId: string, payload: AddAgentCapabilityPayload, ): Promise { const { data } = await apiRequest<{ data: AddAgentCapabilityResult }>( `/agent/${agentId}/capabilities`, { method: 'POST', body: jsonBody(payload) }, ) return data } // ============================================================================ // Versions & publishing // ============================================================================ // Version history helpers (getAgentHistory, getLatestAgentCommit) live in // agent-test-cases.ts and are exported on the same `tela` global. /** * Restore the agent to a previous commit (creates a new commit on top) */ export async function restoreAgentVersion( agentId: string, commitHash: string, options?: AgentCommitOptions, ): Promise { const { data } = await apiRequest<{ data: RestoreAgentVersionResult }>( `/agent/${agentId}/history/${commitHash}/restore`, { method: 'POST', body: jsonBody({ branch: options?.branch, commitMessage: options?.commitMessage }) }, ) return data } /** * Publish an agent version. runAgent (production) executes the published * commit; testAgent (draft) executes main HEAD. */ export async function publishAgent(agentId: string, commitHash: string): Promise { const { data } = await apiRequest<{ data: PublishAgentResult }>(`/agent/${agentId}/publish`, { method: 'POST', body: jsonBody({ commitHash }), }) return data } // ============================================================================ // Execution & sessions // ============================================================================ /** * Execute the agent in draft mode (main HEAD by default). Returns 202 with a * sessionId — the run is async; wait with waitForAgentSession or use * runAgentAndWait. Pass sessionId to continue a multiturn conversation. */ export async function testAgent(agentId: string, payload: ExecuteAgentPayload): Promise { const { data } = await apiRequest<{ data: ExecuteAgentResult }>(`/agent/${agentId}/test`, { method: 'POST', body: jsonBody(payload), }) return data } /** * Execute the agent in production mode (published commit by default). * Same async semantics as testAgent; supports webhooks. */ export async function runAgent(agentId: string, payload: RunAgentPayload): Promise { const { data } = await apiRequest<{ data: ExecuteAgentResult }>(`/agent/${agentId}/run`, { method: 'POST', body: jsonBody(payload), }) return data } /** * Get the session's conversation thread (turns with user/assistant messages, * structured content, and overall status) */ export async function getAgentSessionThread(sessionId: string): Promise { const { data } = await apiRequest<{ data: AgentSessionThread }>(`/agent/sessions/${sessionId}/thread`) return data } /** * Get the session's structured timeline (events, tool calls, metrics, cost) */ export async function getAgentSessionTimeline(sessionId: string): Promise { const { data } = await apiRequest<{ data: AgentSessionTimeline }>(`/agent/sessions/${sessionId}/timeline`) return data } /** * List files the agent produced in the session sandbox (output/, etc.) */ export async function getAgentSessionFiles(sessionId: string): Promise { const { data } = await apiRequest<{ data: AgentSessionFiles }>(`/agent/sessions/${sessionId}/files`) return data } /** * Read one file produced in the session sandbox */ export async function getAgentSessionFile(sessionId: string, filePath: string): Promise { const { data } = await apiRequest<{ data: AgentSessionFileContent }>( `/agent/sessions/${sessionId}/files/${encodeFilePath(normalizePath(filePath))}`, ) return data } /** * Cancel a running session */ export async function cancelAgentSession(sessionId: string): Promise { const { data } = await apiRequest<{ data: unknown }>(`/agent/sessions/${sessionId}/cancel`, { method: 'POST' }) return data } /** * End a multiturn session (stops the sandbox waiting for messages) */ export async function endAgentSession(sessionId: string): Promise { const { data } = await apiRequest<{ data: unknown }>(`/agent/sessions/${sessionId}`, { method: 'DELETE' }) return data } /** * Poll a session until it settles. Resolved statuses: completed, failed, * cancelled, or waiting_messages (multiturn agent finished its turn and is * waiting for the next user message). */ export async function waitForAgentSession( sessionId: string, options?: { intervalMs?: number, maxAttempts?: number }, ): Promise<{ thread: AgentSessionThread, status: AgentSessionStatus }> { const interval = options?.intervalMs ?? 3000 const maxAttempts = options?.maxAttempts ?? 400 // 20 minutes default const settled = new Set(['completed', 'failed', 'cancelled', 'waiting_messages']) for (let i = 0; i < maxAttempts; i++) { let thread: AgentSessionThread | null = null try { thread = await getAgentSessionThread(sessionId) } catch (error) { // The session/thread materializes asynchronously after the 202 if (!(error instanceof ApiRequestError) || error.status !== 404) { throw error } } if (thread && settled.has(thread.status as AgentSessionStatus)) { return { thread, status: thread.status as AgentSessionStatus } } await new Promise(resolve => setTimeout(resolve, interval)) } throw new Error(`Agent session ${sessionId} did not settle within timeout`) } /** * Execute the agent and wait for the result in one call. Uses draft mode * (testAgent) unless published: true. Returns the final assistant output — * structuredContent when the agent has an output-format.json, text otherwise. */ export async function runAgentAndWait( agentId: string, payload: ExecuteAgentPayload, options?: { published?: boolean, intervalMs?: number, maxAttempts?: number }, ): Promise<{ sessionId: string, status: AgentSessionStatus, output: unknown, thread: AgentSessionThread }> { const { sessionId } = options?.published ? await runAgent(agentId, payload) : await testAgent(agentId, payload) const { thread, status } = await waitForAgentSession(sessionId, options) const lastAssistant = [...thread.turns].reverse().find(turn => turn.assistant)?.assistant const output = lastAssistant?.structuredContent ?? lastAssistant?.text ?? null return { sessionId, status, output, thread } } // ============================================================================ // Helper Functions // ============================================================================ /** * Build the execution inputs array from a simple Record of variable values. * Values starting with vault:// become file inputs; everything else is text. * Same conversion as test case inputs (buildAgentTestInputs). */ export function buildAgentExecutionInputs( variables: Record, options?: { fileNames?: Record }, ): AgentExecutionInput[] { return buildAgentTestInputs(variables, options) } /** * Get the URL to view/edit an agent in the Tela app */ export function getAgentUrl(agentId: string): string { return `${getAppBaseUrl()}/agent/${agentId}` }