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 с отступом в два пробела |
число, логическое значение, null | JSON-текст значения |
Форма результата: 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 | целое от 0 | 0 | сколько попыток выполнить после неудачной |
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.
Агенты этапов выполняют любые команды без подтверждения. Читайте список этапов в вопросе и запускайте только те 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:
- Создаёт папку
<проект>/.veai/tmp-workflows/<имя>/сWORKFLOW.tsи файлами этапов. Прямо в.veai/workflows/он их не кладёт: там это стал бы найденный workflow с командой/имя. - Вызывает
check_workflowсpathи исправляет проблемы до ответаOK. - Вызывает
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}}