Skip to content

Creating Custom Workflows

A workflow defines what the bot does each cycle — its decision loop. Every instance has exactly one workflow, set in instance.yaml. The built-in jira-sprint workflow handles the full autonomous development loop, but you can create custom workflows for specialized use cases like monitoring, scheduled tasks, or domain-specific automation.

Built-in vs Custom Workflows

Built-in (core image)

Reference a workflow by name. It resolves from the core bot image at presets/workflows/<name>/:

# instance.yaml
workflow: jira-sprint

Custom (instance config repo)

Use the ./ prefix to reference a workflow relative to your instance config repo's agent directory:

# instance.yaml
workflow: ./workflows/watchduty

This resolves to <your-config-repo>/agent/workflows/watchduty/. Use this when your workflow is specific to your team and doesn't belong in the core bot image.

Directory Structure

A workflow directory contains:

workflows/my-workflow/
├── CLAUDE.md              # Required — the bot's instructions for this workflow
├── manifest.yaml          # Recommended — metadata, requirements, preflight declaration
├── preflight/             # Optional — pre-session data-gathering scripts
│   ├── 01-check-service.py
│   └── 02-check-capacity.py
└── skills/                # Optional — workflow-specific skill scripts
    └── my-skill/
        ├── SKILL.md
        └── my_skill.py

Required: CLAUDE.md

This is the core of your workflow. At startup, the runner assembles the final CLAUDE.md by combining:

  1. Core instructions (presets/core/CLAUDE.md) — security rules, tool usage, general behavior
  2. Workflow instructions (your workflow's CLAUDE.md) — what to do each cycle
  3. Instance instructions (your config repo's agent/CLAUDE.md) — instance-specific overrides (strategy-dependent)

Your workflow CLAUDE.md should be written as imperative instructions telling the bot what to do. Think of it as a runbook.

Declares metadata and requirements. Validated at startup — missing required MCP servers or env vars cause a fatal error, catching deployment misconfigurations early.

Optional: preflight/

Pre-session scripts that run before each Claude session. See Writing Custom Preflight Scripts for the full guide.

Optional: skills/

Python scripts that the bot can invoke during its session (e.g. triage logic, data fetching). These are copied to .claude/skills/ at startup.

manifest.yaml Schema

name: my-workflow
type: workflow
description: Brief description of what this workflow does

# Preflight scripts (documentation — discovery is filesystem-based)
preflight:
  - 01-check-service.py
  - 02-check-capacity.py

# Core skills to include alongside this workflow's own skills
shared_skills:
  - push-and-pr        # Git push + PR creation
  - post-pr            # Post-PR Jira comment + Slack notification
  - auto-fork          # Fork creation for new repos

# What this workflow provides
provides:
  claude_md: CLAUDE.md
  skills:
    - my-skill          # Skill directories under this workflow's skills/

# What this workflow requires to function
requires:
  mcp_servers:
    - bot-memory         # Must be configured in MCP settings
    - mcp-atlassian
  env_vars:
    - BOT_LABEL          # Fatal if not set
    - BOT_INSTANCE_ID
  optional_env_vars:
    - SLACK_WEBHOOK_URL  # Warning if not set, but not fatal

Field Reference

Field Required Description
name Yes Workflow identifier
type Yes Must be workflow
description Yes One-line summary
preflight No List of preflight script filenames (documentation only)
shared_skills No Core skill names to include (from presets/shared/skills/)
provides.claude_md No CLAUDE.md filename (always CLAUDE.md)
provides.skills No Skill directory names provided by this workflow
requires.mcp_servers No MCP servers that must be configured. Fatal if missing.
requires.env_vars No Environment variables that must be set. Fatal if missing.
requires.optional_env_vars No Env vars that should be set. Warning if missing.

instance.yaml Configuration

One instance = one workflow. Each instance directory maps to its own OpenShift deployment (own pod, own schedule, own task queue). If you need a second workflow for the same repos, create a second instance directory:

instance/
  rbac-config/              # Jira-driven workflow
    agent/
      instance.yaml         # workflow: jira-kanban
      project-repos.json
  rbac-config-konflux/      # Separate workflow for the same repos
    agent/
      instance.yaml         # workflow: ./workflows/konflux-pr-squash
      project-repos.json    # Can reference the same repos
      workflows/
        konflux-pr-squash/
          ...

Both instances share the memory server and can see each other's tasks, but run independently on their own schedules.

Your instance config repo's instance.yaml controls which workflow runs and how:

# instance/<config>/agent/instance.yaml
workflow: ./workflows/my-workflow   # ./ = relative to this agent dir
source: scheduled                   # or "jira"
envs:
  - slack
  - node
claude_md:
  strategy: append                  # ignore | append | replace

Fields

Field Default Description
workflow jira-sprint Workflow name (built-in) or ./path (custom)
source jira Ticket source. jira = Jira sprint polling. scheduled = time-based.
envs null (all) Env presets to activate. null = all available, [] = none.
claude_md.strategy ignore How instance CLAUDE.md combines with workflow CLAUDE.md

CLAUDE.md Assembly Strategies

The claude_md.strategy field controls how your instance's agent/CLAUDE.md combines with the workflow's CLAUDE.md:

ignore (default)

Final CLAUDE.md = core + workflow

Instance CLAUDE.md is ignored. Use when the workflow's instructions are complete and the instance doesn't need to add anything.

When to use: Most workflows. The workflow CLAUDE.md has everything the bot needs.

append

Final CLAUDE.md = core + workflow + instance

Instance CLAUDE.md is appended after the workflow instructions. Use when you want to add instance-specific context on top of the workflow.

When to use: Your team has extra context (team conventions, specific repos, escalation contacts) that supplements the workflow's decision loop.

# instance.yaml
claude_md:
  strategy: append
<!-- agent/CLAUDE.md (instance) -->
## Team-Specific Context

- Our Jenkins is at jenkins.example.com
- Escalate P0 issues to #team-oncall in Slack
- Prod jobs are prefixed with "prod-"

replace

Final CLAUDE.md = core + instance

The workflow's CLAUDE.md is skipped entirely. Instance CLAUDE.md replaces it. Use when you want complete control over the bot's instructions while still keeping core security rules.

When to use: Rarely. Only when the workflow is just a container for preflight/skills and the instructions are fully custom per instance.

Path Resolution

The resolve_workflow_dir() function in bot/config.py handles path resolution:

  • Plain name (e.g. jira-sprint) → <bot-repo>/presets/workflows/jira-sprint/
  • ./ prefix (e.g. ./workflows/watchduty) → <config-repo>/agent/workflows/watchduty/

This resolution is used consistently across: - Startup validation (validate_instance_config) - Manifest loading and validation (load_manifest, validate_manifest) - CLAUDE.md assembly (assemble_claude_md) - Preflight script discovery (discover_preflight_scripts)

What Workflows Don't Handle

Workflows define the decision loop. Everything else comes from instance config merging:

Handled by workflow Handled by instance config merge
CLAUDE.md instructions Personas
Preflight scripts Skills (instance-level)
Workflow-specific skills MCP server configs
Settings (.claude/settings.json)
Hooks (.claude/hooks/)
project-repos.json

The merge system (bot/merge.pyapply_merged_config()) combines persona files, MCP configs, skills, settings, and hooks from both the core image and your instance config. Workflows are independent of this — they provide their own CLAUDE.md and preflight scripts.

Complete Example: Review-Only Workflow

Let's build a workflow that only reviews PRs — no new Jira work, no ticket claiming.

1. Create the directory

instance/my-config/agent/
├── instance.yaml
├── workflows/
│   └── review-only/
│       ├── CLAUDE.md
│       ├── manifest.yaml
│       └── preflight/
│           └── 01-check-prs.py
└── project-repos.json

2. Write the CLAUDE.md

# Review-Only Workflow

Each cycle: check for PRs needing review, review them, post feedback.

## Cycle Loop

1. Read the preflight data — it contains PR statuses and review requests.
2. If no PRs need attention, stop.
3. For each PR needing review (one per cycle):
   a. Clone the repo if needed
   b. Read the diff
   c. Post a code review with actionable feedback
   d. Update task tracking

## Rules

- ONE PR per cycle
- Only review, never push code
- Be constructive — suggest improvements, don't just list problems

3. Write the manifest.yaml

name: review-only
type: workflow
description: Review PRs without implementing new work

preflight:
  - 01-check-prs.py

shared_skills:
  - push-and-pr    # For reading PR templates/conventions

provides:
  claude_md: CLAUDE.md

requires:
  mcp_servers:
    - bot-memory
  env_vars:
    - BOT_INSTANCE_ID
  optional_env_vars:
    - SLACK_WEBHOOK_URL

4. Write the preflight script

#!/usr/bin/env python3
"""Pre-flight: check for PRs needing review."""

import json
import sys

from common import get_tasks, get_task_prs, output_result


def main():
    tasks = get_tasks()
    review_needed = []

    for task in tasks:
        if task.get("status") not in ("pr_open", "pr_changes"):
            continue
        prs = get_task_prs(task)
        if prs:
            review_needed.append(
                {
                    "key": task.get("external_key", "?"),
                    "repo": task.get("repo", "?"),
                    "prs": prs,
                }
            )

    if not review_needed:
        output_result("skip", f"{len(tasks)} tasks, none with PRs needing review.")
        return

    content = f"{len(review_needed)} PRs need review:\n"
    content += json.dumps(review_needed, indent=2)
    output_result("start", content)


if __name__ == "__main__":
    main()

5. Configure instance.yaml

workflow: ./workflows/review-only
source: jira
envs:
  - slack
claude_md:
  strategy: ignore    # Workflow CLAUDE.md has everything needed

Real-World Pattern: Scheduled Monitoring

For workflows that run on a schedule (not driven by Jira), use source: scheduled and a preflight script that checks an external service:

# instance.yaml
workflow: ./workflows/watchduty
source: scheduled
envs:
  - slack
claude_md:
  strategy: append

The key differences from a Jira-driven workflow: - source: scheduled — The bot runs on a timer rather than polling Jira for new tickets - Preflight decides everything — No Jira triage phase. The preflight script checks the external service and determines whether action is needed. - strategy: append — Instance CLAUDE.md adds team-specific context (service URLs, escalation paths) on top of the workflow's general instructions.

The preflight script for this pattern typically: 1. Fetches data from the external service (Jenkins, Grafana, custom API) 2. Classifies items deterministically (failing vs healthy, priority ordering) 3. Outputs start with only the actionable items, or skip if everything is healthy

See Writing Custom Preflight Scripts for the implementation details.

Task Tracking

Custom workflows use the same memory server task system as built-in workflows. The agent creates and updates tasks via MCP tools (task_add, task_update, task_get, task_remove) — never via raw HTTP calls to the memory server API.

external_key conventions

Every task has an external_key + source_type pair. The DB enforces UNIQUE(external_key, source_type), so this combo is the task's identity.

For Jira-driven workflows, the external_key is the Jira ticket key (RHCLOUD-12345) and source_type defaults to "jira".

For non-Jira workflows, you define your own key format. Use a deterministic, human-readable composite key:

<workflow-name>:<scope>

Examples: - konflux-pr-squash:project-kessel/insights-rbac — consolidation task scoped to a repo - watchduty:jenkins-prod-pipeline — monitoring task scoped to a service - review-only:RedHatInsights/insights-chrome — review task scoped to a repo

If a workflow creates multiple tasks per scope (e.g. one consolidated PR per ecosystem), extend the key: - konflux-pr-squash:project-kessel/insights-rbac:go - konflux-pr-squash:project-kessel/insights-rbac:python

source_type

The task_add MCP tool defaults source_type to "jira". Non-Jira workflows must pass it explicitly:

task_add(
    external_key="konflux-pr-squash:project-kessel/insights-rbac",
    source_type="github",        # NOT the default "jira"
    repo="insights-rbac",
    branch="bot/consolidate-go-deps",
    status="pr_open",
    title="Consolidate 5 Go dependency updates",
    metadata={
        "prs": [{"repo": "project-kessel/insights-rbac", "number": 42, "host": "github"}],
        "original_prs": [101, 102, 103],
        "ecosystem": "go"
    }
)

Getting source_type wrong means task_get lookups fail (it queries by external_key + source_type), and preflight scripts that check for in-progress tasks won't find them.

Why MCP, not HTTP

Your workflow CLAUDE.md should tell the agent to use task_add, not construct HTTP requests. The MCP tool: - Enforces the capacity cap (max 10 active tasks) — raw HTTP bypasses this - Publishes events to the SSE bus (dashboard updates, Slack notifications) - Builds artifact links automatically (Jira URLs, PR links) - Validates input and returns structured errors

Preflight integration

Preflight scripts check the task system to avoid duplicate work. Your workflow's preflight should:

  1. Call get_tasks() to fetch active tasks
  2. Filter for tasks matching your workflow's external_key prefix
  3. Skip if a matching task is already in progress
from common import get_tasks, get_capacity, output_result

TASK_KEY_PREFIX = "my-workflow:"

tasks = get_tasks()
active = [t for t in tasks if t.get("status") in ("in_progress", "pr_open", "pr_changes")]

# Already working on this?
my_tasks = [t for t in active if t.get("external_key", "").startswith(TASK_KEY_PREFIX)]
if my_tasks:
    output_result("skip", f"Already in progress: {my_tasks[0]['external_key']}")
    return

# Respect capacity
active_n, max_n = get_capacity()
if active_n >= max_n:
    output_result("skip", f"At capacity ({active_n}/{max_n})")
    return

The built-in gh_pr_status.py preflight monitors all pr_open tasks automatically — no per-workflow polling needed. Create a task with status="pr_open" and PR metadata, and CI monitoring happens for free.

Checklist: Shipping a Custom Workflow

  1. Create the workflow directory in your config repo under agent/workflows/<name>/

  2. Write CLAUDE.md — imperative instructions for the bot's decision loop. Be specific about:

  3. What to check each cycle
  4. When to take action vs skip
  5. How to report results
  6. What "done" looks like

  7. Write manifest.yaml — declare requirements so deployment failures are caught at startup, not mid-session

  8. Add preflight scripts (if needed) — pre-session data gathering to avoid wasting tokens on idle cycles

  9. Update instance.yaml — set workflow: ./workflows/<name> and choose the right claude_md.strategy

  10. Test locally:

    # Test preflight scripts standalone
    cd /path/to/dev-bot
    PYTHONPATH=presets/shared/preflight:.claude/skills python3 path/to/preflight/01-check.py
    
    # Test CLAUDE.md assembly
    python3 bot/run.py  # Check assembled output
    

  11. Deploy — set BOT_CONFIG_PATH in your deployment template to point to your instance config