Skip to main content

Workflows

Workflow — короткий TypeScript-скрипт, который запускает субагентов в том порядке, который задаёт скрипт. В скрипте только управление ходом работы: порядок этапов, циклы, ветвления. Все промпты лежат в Markdown-файлах этапов рядом со скриптом.

Зачем это нужно​

  • Порядок задаёт скрипт, а не модель. Когда сценарий многоэтапный, например «найти → проверить каждую находку → собрать отчёт», модель не может пропустить шаг или забыть о нём.
  • Проверенный результат. Этап может описать форму своего ответа, и скрипт ветвится по проверенным данным, а не по свободному тексту.
  • Работа в фоне и продолжение. Запуск идёт в фоне, сохраняет каждый завершённый вызов агента и после сбоя продолжается без повторного запуска того, что уже выполнено.
  • Запуск без интерфейса. Workflow можно вызвать из CI и скриптов командой veai --workflow.

Workflow похож на скилл, но устроен иначе. Скилл — инструкция, которой модель следует по своему усмотрению. Workflow — программа: порядок шагов жёстко задан, а модель выполняет только отдельные этапы. Если скилл описывает многоэтапный процесс, а модель пропускает шаги, его стоит переписать на workflow. Этапы запускаются как субагенты: в них можно выбрать встроенного или свой агент и профиль модели.

Пример: аудит маршрутов​

Папка <проект>/.veai/workflows/audit/:

WORKFLOW.ts
list-route-files.md
audit-file.md

WORKFLOW.ts:

export const meta = {
name: "audit-routes",
description: "Найти обработчики маршрутов без проверки авторизации",
}

const listing = await stage("list-route-files", { dir: args }) as { files: string[] }
const audits = await forEach(listing.files, (file: string) =>
stage("audit-file", { file }, { label: file, agent: "hawkeye", retries: 1 }),
{ onError: "skip" },
)
return audits

list-route-files.md:

---
name: list-route-files
returns:
files: [string]
---
Перечисли все файлы в {{dir}}, где объявлены обработчики HTTP-маршрутов.

audit-file.md:

---
name: audit-file
returns:
file: string
issues:
- line: integer
severity: high | medium | low
---
Проверь {{file}}. Назови каждый обработчик, который обращается к данным без проверки авторизации.

Запуск из чата: /audit-routes src/api. Текст после имени попадает в скрипт как args. Полный пример с проверкой находок и итоговым отчётом — в конце страницы.

Где лежат workflows​

КореньГде действует
~/.veai/workflows/во всех проектах
<проект>/.veai/workflows/только в текущем проекте

Workflow — это непосредственная подпапка корня, в которой есть файл WORKFLOW.ts. Файлы прямо в корне workflow'ами не считаются, вложенные папки глубже не ищутся, родительские каталоги не просматриваются, а другие каталоги, например .claude/workflows/, не читаются. Имя папки не важно: идентификатор workflow — meta.name.

Папку можно создать самому, а можно попросить агента: «Сделай workflow, который прогоняет тесты по модулям и собирает отчёт».

Состав папки​

<корень>/<папка>/
WORKFLOW.ts скрипт, обязателен
<любое>.md этап, если во frontmatter есть name
README.md не этап, если во frontmatter нет name
.runs/ создаёт продукт, по папке на каждый запуск

Скрипт WORKFLOW.ts​

export const meta = {
name: "audit-routes",
description: "Найти обработчики маршрутов без проверки авторизации",
}
  • meta экспортируется ровно в таком виде, export const meta = { name, description }, и это единственный экспорт.
  • name и description — обычные строковые литералы, не шаблонные строки и не переменные, оба непустые. Других ключей в meta быть не может.
  • name соответствует ^[a-z0-9]+(-[a-z0-9]+)*$, не равно workflows и не совпадает с именем встроенной команды. Это имя команды: audit-routes запускается как /audit-routes.
  • В скрипте можно использовать аннотации типов TypeScript, они удаляются перед запуском. Синтаксис, который требует преобразования, например enum и namespace, не принимается, как и любой import.
  • В теле скрипта работают await и return верхнего уровня. Возвращённое значение — результат запуска. Это должен быть JSON (объект, массив, строка, число, логическое значение или null) либо ничего.

Каждый вызов stage(...) проверяется до запуска, без выполнения скрипта: первым аргументом должна быть строка-литерал с именем файла этапа из этой же папки.

Глобальные объекты​

В скрипте доступны только эти объекты, кроме встроенных возможностей языка:

ОбъектВидЧто делает
argsстрокатекст запуска, пустая строка, если его нет
stage(name, inputs?, options?) => Promise<object | string>запускает один этап как одного субагента
forEach(items, fn, { onError? }?) => Promise<array>пропускает элементы через fn одновременно
parallel(thunks) => Promise<array>запускает функции одновременно и отклоняется так же, как Promise.all
phase(title) => voidначинает именованную группу следующих вызовов stage
log(message) => voidдобавляет строку в журнал запуска

В скрипте нет доступа к require, import(), process, файловой системе и сети. Любой побочный эффект выполняет агент этапа.

Детерминизм​

При продолжении запуска скрипт выполняется заново с начала, а уже завершённые вызовы этапов получают ответ из журнала. Поэтому скрипт должен каждый раз идти одним путём. Date.now(), Math.random() и new Date() без аргументов в скрипте выбрасывают ошибку, а new Date(значение) с аргументом работает. Всё, что меняется от запуска к запуску, должно приходить в результате этапа: он записывается в журнал.

Файлы этапов​

Этап — файл *.md прямо в папке workflow, во frontmatter которого есть name. Тело файла после frontmatter — промпт.

КлючОбязателенЗначение
nameданепустая строка, уникальная в папке; идентификатор этапа
returnsнетформа результата в формате, описанном ниже

Других ключей во frontmatter этапа быть не может. Markdown-файл без name этапом не считается и не проверяется.

Подстановки​

Подстановка записывается как {{ключ}}, где ключ соответствует ^[A-Za-z_][A-Za-z0-9_]*$. Любой другой текст внутри двойных фигурных скобок, в том числе с пробелами ({{ file }}), делает workflow недопустимым. Для каждой подстановки нужен входной параметр с тем же ключом, и для каждого параметра нужна подстановка, иначе вызов этапа завершится ошибкой до запуска агента. Параметр со значением undefined считается отсутствующим.

Значение параметраКак подставляется
строкакак есть
объект или массивJSON с отступом в два пробела
число, логическое значение, nullJSON-текст значения

Форма результата: returns​

returns — YAML, корнем которого служит непустой словарь. Он становится схемой инструмента report_result у агента, поэтому результат проверяется до того, как его увидит скрипт.

ЗаписьЧто означает
string, number, integer, booleanэтот скалярный тип
ключ: <тип> в словареобъект, все ключи обязательны
ключ?: <тип>необязательный ключ
[<тип>], последовательность из одного элементамассив этого типа
high | medium | lowстрока, одно из перечисленных значений. Элементом не может быть имя скалярного типа
returns:
file: string
issues:
- line: integer
severity: high | medium | low
evidence?: string

Описаний полей и сырой JSON Schema нет: запись { type: string } читается как объект с ключом type. Простые значения перед проверкой могут приводиться к типу, например "3" к 3.

Если returns задан, stage() возвращает проверенный объект. Без returns агент сообщает обычный текст, и stage() возвращает строку.

Параметры вызова этапа​

stage(name, inputs, options) принимает такие параметры в options:

ПараметрТипПо умолчаниюЧто делает
agentстроканетвспомогательный агент, как аргумент agent у subagent. См. «Субагенты»
modelProfileстроканетпрофиль модели, как model_profile у subagent
timeoutMsположительное целоебез ограниченияостанавливает попытку, которая идёт дольше
retriesцелое от 00сколько попыток выполнить после неудачной
labelнепустая строка<имя этапа> #<n>имя агента только в представлениях хода работы

Файл этапа не выбирает агента и профиль, это делает вызов в скрипте:

stage("audit-file", { file, methods }, { label: file, agent: "hawkeye", retries: 1 })

Сбои, повторы и ограничения​

  • Если агент этапа завершился ошибкой, был отменён или потерял связь, stage() отклоняется и никогда не возвращает null. Неперехваченное отклонение завершает запуск как failed.
  • forEach(items, fn) отклоняется, если отклонился любой вызов. С { onError: "skip" } он возвращает успешные результаты в порядке элементов, а каждый пропущенный сбой попадает в запись запуска.
  • retries запускает нового агента для каждой попытки и покрывает любые сбои, кроме остановки пользователем. В журнал попадает только успешная попытка.
  • Запуск держит одновременно не больше 8 агентов этапов, остальные вызовы ждут в порядке вызова. Агенты этапов учитываются и в subagents.maxConcurrency (см. «Настройки»): пока пул занят, вызов этапа ждёт, а не завершается ошибкой.
  • Запуск стартует не больше 200 агентов этапов, считая повторы. Воспроизведённые из журнала вызовы не считаются. Вызов, для которого нужен 201-й агент, завершает запуск как failed.
  • Запуск заканчивается в одном из трёх состояний: done (скрипт вернул результат), failed (скрипт выбросил ошибку, упёрся в лимит или вернул не JSON), stopped (остановил пользователь или закончилась сессия).

Запуск​

СпособКак
Пользователь в чате/audit-routes src/api, только изменяющие данные обработчики. Текст после имени — это args
Модельинструмент run_workflow по name или по path
CI, без интерфейсаveai --workflow audit-routes src/api

run_workflow принимает name (найденный workflow) или path (папку workflow, относительно папки сессии или абсолютную), но не оба сразу, а также необязательные args и resume. Инструмент возвращает управление сразу после старта вместе с идентификатором запуска, а итог приходит позже отдельным сообщением. Опрашивать его не нужно. Субагент запустить workflow не может.

Команда /workflows показывает все workflow со статусом (● on, ○ off (project), ○ off (global), ✗ invalid, ✗ duplicate name) и причиной, запуски в текущем проекте и группу Temporary для workflow, запущенных по пути. Enter на workflow включает и выключает его, Enter на запуске открывает хронологию, а r продолжает запуск, который можно продолжить.

Вопрос перед запуском и разрешения​

Каждый запуск из терминала в режимах default и paranoid сначала задаёт вопрос. В режимах bypassPermissions и afk запуск идёт без вопроса. В вопросе указаны имя workflow, полный путь к его папке и список этапов, а также предупреждение, что его агенты работают без проверок разрешений.

  • Отказ ничего не запускает.
  • Вопрос без ответа отклоняется автоматически через 5 минут, и запуск завершается ошибкой was not answered within 5 minutes. Запустите workflow снова, когда вернётесь.
  • Все агенты этапов работают в режиме bypassPermissions независимо от режима сессии: вы подтвердили это ответом на вопрос, либо режим сессии его не требует.
  • Запуск без интерфейса ничего не спрашивает.
  • Если агент запускает veai --workflow … через bash, политика разрешений спрашивает вас перед командой в режимах default и paranoid. Чтобы запустить workflow из сессии, агент должен вызывать run_workflow.
warning

Агенты этапов выполняют любые команды без подтверждения. Читайте список этапов в вопросе и запускайте только те workflow, которым доверяете.

Запуск без интерфейса​

veai --workflow <имя> [--resume [<id-запуска>]] [<текст>...]

В stdout попадает только результат запуска: JSON для объекта или массива, чистый текст для строки, пустой вывод, если результата нет. Поэтому работает, например, veai --workflow audit-routes src/api | jq .confirmed. Ход выполнения, строки log и причины сбоев идут в stderr. Путь к папке вместо имени не принимается.

Код выходаКогда
0запуск закончился done
1запуск закончился failed
2запуск не удался до старта агентов: неизвестный, недоступный или недопустимый workflow, нечего продолжать, неверные аргументы
130запуск закончился stopped после прерывания

Запуск veai -p и общие правила CI описаны на странице «CI и запуск без терминала».

Продолжение запуска​

Обычный запуск всегда начинает новый прогон. Продолжение возобновляет прогон в состоянии failed или stopped под тем же идентификатором и с теми же args, с которыми он стартовал:

  • /audit-routes --resume или --resume <id-запуска>; текст после флага игнорируется с уведомлением.
  • run_workflow с resume: true (последний прогон этого workflow в проекте, который можно продолжить) или resume: "<id-запуска>".
  • veai --workflow audit-routes --resume [<id-запуска>].

Продолжайте только по вашей просьбе: прогон, который вы остановили сами, остаётся остановленным. Пока одна сессия продолжает прогон, продолжение того же прогона из другой сессии завершается ошибкой до окончания первого.

Скрипт выполняется заново с начала. Вызов этапа, у которого совпали имя этапа, заполненный промпт, схема returns, агент и профиль с записью в журнале, получает сохранённый результат без запуска агента. N-й из нескольких одинаковых вызовов сопоставляется с N-м результатом в журнале. Поэтому правка промпта этапа перезапустит вызовы этого этапа, а остальные воспроизведутся из журнала. Параметр label в сопоставлении не участвует.

Папка запуска​

Каждый запуск хранится в <папка workflow>/.runs/<id-запуска>/:

.runs/.gitignore игнорирует всё внутри .runs/
.runs/<id-запуска>/run.json имя, папка, путь проекта, args, время старта и конца, состояние,
результат или причина сбоя, фазы, агенты и строки log
.runs/<id-запуска>/journal.jsonl завершённые вызовы этапов с ключами и результатами

В запуске записан абсолютный путь проекта: продолжение и /workflows учитывают только запуски текущего проекта. Продукт сам запуски не удаляет, ненужную папку .runs/<id-запуска>/ удалите вручную. Запуск по пути дополнительно дописывает строку в <проект>/.veai/workflows/.temporary-runs.jsonl, который игнорируется git.

Отключение workflow​

Ключ workflows.disabledWorkflows в settings.json (см. «Настройки») хранит точные имена workflow с учётом регистра. Он работает на обоих уровнях:

{
"workflows": {
"disabledWorkflows": ["audit-routes"]
}
}

У отключённого workflow нет команды, он не виден модели и не запускается по имени. Запуск по пути эту настройку игнорирует. Включать и выключать workflow можно и в окне /workflows.

Недоступные и недопустимые workflow​

  • Два workflow с одинаковым meta.name недоступны оба, в каком бы корне они ни лежали.
  • Workflow и скилл с одним именем недоступны оба.
  • Workflow недопустим, если не прошла любая из проверок: meta, вызов stage, файл этапа, два файла этапов с одним name, значение returns, подстановка или запрещённый синтаксис. В причине названы поле, файл или строка. Недопустимый workflow нельзя запустить ни по имени, ни по пути.

Проверка перед запуском​

После написания или правки workflow, перед run_workflow, вызовите инструмент check_workflow с name (найденный workflow) или path (папка workflow). Он не выполняет скрипт, ничего не спрашивает и ничего не запускает, а субагент тоже может его вызывать. Он выводит все проблемы сразу, по одной в строке:

WORKFLOW.ts:3 — stage "clasify" has no stage file. Fix: create clasify.md with "name: clasify" in its frontmatter, or use the existing stage "classify".

Исправьте и вызовите снова, пока не придёт ответ OK. Для допустимого workflow ответ перечисляет этапы с полями returns и подстановками, корень, в котором он лежит, и запускается ли он командой /name. По имени ответ также объясняет, почему нет: дубль имени (со всеми путями) или workflows.disabledWorkflows (с файлом настроек для правки).

Временные workflow, которые пишет агент​

Если агенту нужно провести несколько субагентов по фиксированной процедуре, например «найти → проверить каждую находку → свести» или одно и то же действие над списком, он может сам написать временный workflow:

  1. Создаёт папку <проект>/.veai/tmp-workflows/<имя>/ с WORKFLOW.ts и файлами этапов. Прямо в .veai/workflows/ он их не кладёт: там это стал бы найденный workflow с командой /имя.
  2. Вызывает check_workflow с path и исправляет проблемы до ответа OK.
  3. Вызывает run_workflow с тем же path. Заранее у вас ничего не спрашивается: вопрос перед запуском задаётся до старта агентов.

Первый запуск из .veai/tmp-workflows/ создаёт там .gitignore, который игнорирует всё, поэтому временные workflow и их запуски не попадают в git. Продукт ничего оттуда не удаляет. Все запуски по пути видны в группе Temporary окна /workflows.

Временный workflow запускается только по пути: у него нет команды /имя, и он не виден модели в каталоге. Если сценарий нужен постоянно, перенесите его в корень workflow. Для единичной задачи, для исследования, где следующий шаг зависит от найденного, и для работы, которой нужен ваш ответ посреди выполнения, агент использует обычные субагенты.

Полный пример​

<проект>/.veai/workflows/audit/WORKFLOW.ts:

export const meta = {
name: "audit-routes",
description: "Найти обработчики маршрутов без проверки авторизации и проверить каждую находку",
}

phase("Discover")
const request = await stage("parse-request", { text: args }) as { dir: string; methods: string[] }
const listing = await stage("list-route-files", { dir: request.dir }, { agent: "retriever", modelProfile: "cheap" }) as { files: string[] }
log(`найдено файлов с маршрутами: ${listing.files.length}`)

phase("Audit")
const audits = await forEach(listing.files, (file: string) =>
stage("audit-file", { file, methods: request.methods }, { label: file, agent: "hawkeye", retries: 1 }),
{ onError: "skip" },
) as { file: string; issues: { line: number; severity: string; evidence?: string }[] }[]

phase("Verify")
const findings = audits.flatMap((audit) => audit.issues.map((issue) => ({ file: audit.file, ...issue })))
const verdicts = await forEach(findings, (finding) => stage("verify-finding", { finding })) as { real: boolean; reason: string }[]
const confirmed = findings.filter((_, index) => verdicts[index].real)

phase("Summarize")
const report = await stage("write-report", { findings: confirmed })
return { confirmed, report }

parse-request.md:

---
name: parse-request
returns:
dir: string
methods: [string]
---
Прочитай запрос и выдели каталог для аудита и HTTP-методы, которые нужно проверить
(все методы, если ни один не назван): {{text}}

list-route-files.md:

---
name: list-route-files
returns:
files: [string]
---
Перечисли все файлы в {{dir}}, где объявлены обработчики HTTP-маршрутов.

audit-file.md:

---
name: audit-file
returns:
file: string
issues:
- line: integer
severity: high | medium | low
evidence?: string
---
Проверь {{file}}. Для каждого обработчика этих методов: {{methods}} — назови те, которые
обращаются к данным без проверки авторизации.

verify-finding.md:

---
name: verify-finding
returns:
real: boolean
reason: string
---
Проверь, настоящая ли это находка, прочитав код вокруг неё: {{finding}}

У write-report.md нет returns, поэтому stage() вернёт его текст:

---
name: write-report
---
Напиши короткий отчёт по этим подтверждённым находкам, сгруппировав их по файлам: {{findings}}