Skip to main content

Workflows

JJHub workflows are TSX files that define automated tasks for your repository. They use Smithers JSX components wrapped by the @jjhub-ai/workflow package. There is no distinction between CI workflows and AI workflows — a workflow task can run tests, deploy code, or invoke an AI agent. They are all just code running in the same sandboxed environment. Workflows live in your repository under .jjhub/workflows/ and are triggered by events like pushes, landing request activity, schedules, or manual dispatch.

Workflow Basics

A workflow is a TSX file that default-exports a function returning a <Workflow> component with a name, triggers, and child tasks.

Minimal Example

// .jjhub/workflows/ci.tsx
import { Workflow, Task, on } from "@jjhub-ai/workflow";
import { $ } from "bun";

export default (ctx) => (
  <Workflow
    name="CI"
    triggers={[on.push({ bookmarks: ["main"] })]}
  >
    <Task id="test">
      {async () => {
        await $`bun test`;
      }}
    </Task>
  </Workflow>
);
This workflow runs bun test every time code is pushed to the main bookmark.

Workflow Structure

Every workflow has three parts:
PartDescription
nameHuman-readable name displayed in run lists and status checks
triggersEvents that cause the workflow to run
Children<Task>, <Parallel>, <Sequence>, <Branch>, or <Ralph> components

Component Reference

ComponentDescription
<Workflow>Root container with name and triggers props
<Task>Unit of work — compute (async fn child), agent (string child + agent prop), or static (object child)
<Parallel>Run children concurrently
<Sequence>Run children sequentially (implicit at workflow root)
<Branch>Conditional execution based on runtime values
<Ralph>Retry/iteration loop with configurable attempts

Directory Layout

Workflows must be placed in the .jjhub/workflows/ directory at the repository root:
your-repo/
├── .jjhub/
│   ├── package.json          # Depends on @jjhub-ai/workflow
│   ├── tsconfig.json         # JSX + Smithers config
│   └── workflows/
│       ├── ci.tsx
│       ├── deploy.tsx
│       ├── triage.tsx
│       └── nightly.tsx
├── packages/
│   └── workflow/             # @jjhub-ai/workflow package
│       ├── package.json
│       ├── tsconfig.json
│       └── src/
│           ├── index.ts      # Re-exports Smithers components + triggers
│           ├── components.tsx # Workflow, Task (JJHub extensions)
│           └── triggers.ts   # on.push(), on.landingRequest.*, etc.
├── package.json              # workspaces: ["packages/*", ".jjhub"]
├── src/
└── ...
Each .tsx file in the workflows/ directory is discovered automatically. The filename (without extension) becomes the workflow ID used in CLI commands like jjhub workflow run ci. The .jjhub/ directory is a Bun workspace member with its own package.json and tsconfig.json, giving full LSP support (autocomplete, type checking, go-to-definition) when editing workflows. See Your First Workflow for the complete setup.

Triggers & Events

Triggers define when a workflow runs. A workflow can have multiple triggers, and will run when any of them fire.

Push

Run when code is pushed to matching bookmarks:
triggers={[
  on.push({ bookmarks: ["main"] }),
]}
You can match multiple bookmarks or use glob patterns:
triggers={[
  on.push({ bookmarks: ["main", "release/*"] }),
]}
Ignore paths to skip runs for certain changes:
triggers={[
  on.push({ bookmarks: ["main"], ignore: ["docs/**", "*.md"] }),
]}

Landing Request

Run when landing request activity occurs:
triggers={[
  on.landingRequest.opened(),      // LR opened or draft published
  on.landingRequest.closed(),      // LR closed without landing
  on.landingRequest.readyToLand(), // All checks pass, reviews approved
  on.landingRequest.landed(),      // LR successfully landed
]}
You can combine multiple landing request events:
triggers={[
  on.landingRequest.opened(),
  on.landingRequest.readyToLand(),
]}

Issue

Run when issue activity occurs:
triggers={[
  on.issue.opened(),     // New issue created
  on.issue.closed(),     // Issue closed
  on.issue.labeled(),    // Label added to issue
  on.issue.commented(),  // Comment added to issue
]}

Schedule

Run on a CRON schedule (all times UTC):
triggers={[
  on.schedule("0 0 * * *"),       // Nightly at midnight UTC
  on.schedule("0 */6 * * *"),     // Every 6 hours
  on.schedule("0 9 * * 1-5"),     // Weekdays at 9am UTC
]}
CRON syntax uses five fields:
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, Sunday=0)
│ │ │ │ │
* * * * *
Common patterns:
PatternCRONDescription
Nightly0 0 * * *Once per day at 00:00 UTC
Every 6 hours0 */6 * * *00:00, 06:00, 12:00, 18:00 UTC
Hourly0 * * * *Start of every hour
Weekly Monday 9am0 9 * * 1Every Monday at 09:00 UTC
Weekdays 8am0 8 * * 1-5Monday—Friday at 08:00 UTC

Manual Dispatch

Run when triggered manually via CLI or API, with optional typed inputs:
triggers={[
  on.manualDispatch({
    inputs: {
      environment: {
        type: "choice",
        required: true,
        options: ["staging", "production"],
        description: "Target environment",
      },
      version: {
        type: "string",
        required: true,
        description: "Version to deploy",
      },
      dry_run: {
        type: "boolean",
        default: false,
        description: "Preview without applying",
      },
    },
  }),
]}
Input types:
TypeDescription
stringFree-form text
booleanTrue or false
numberNumeric value
choiceOne of a set of allowed values (requires options)
Trigger manually from the CLI:
jjhub workflow run deploy --input environment=production --input version=1.2.3
Or via the API:
curl -X POST https://api.jjhub.tech/api/repos/owner/repo/workflows/deploy/dispatches \
  -H "Authorization: token jjhub_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "ref": "main",
    "inputs": {
      "environment": "production",
      "version": "1.2.3"
    }
  }'

Combining Triggers

A workflow can have any combination of triggers. It runs whenever any trigger fires:
triggers={[
  on.push({ bookmarks: ["main"] }),
  on.landingRequest.opened(),
  on.schedule("0 0 * * *"),
]}

Workflow Run

Run when another workflow completes (for chaining workflows like CI -> Build -> Deploy):
triggers={[
  on.workflowRun({ workflows: ["ci"], types: ["completed"] }),
]}
The workflows array lists workflow names to watch. The types array filters by outcome: "completed", "success", or "failure".

Webhook Trigger

Run when an external webhook event is received:
triggers={[
  on.webhook("deployment_status"),
]}

Tasks

Tasks are the units of work a workflow executes. Children of <Workflow> run sequentially by default. Use <Parallel> to run tasks concurrently.

Compute Tasks

A compute task runs an async function. Use Bun’s $ template literal for shell commands:
import { Workflow, Task, on } from "@jjhub-ai/workflow";
import { $ } from "bun";

export default (ctx) => (
  <Workflow
    name="CI"
    triggers={[on.push({ bookmarks: ["main"] })]}
  >
    <Task id="install">
      {async () => {
        await $`bun install`;
      }}
    </Task>
    <Task id="lint">
      {async () => {
        await $`bun run lint`;
      }}
    </Task>
    <Task id="test">
      {async () => {
        await $`bun test`;
      }}
    </Task>
    <Task id="build">
      {async () => {
        await $`bun run build`;
      }}
    </Task>
  </Workflow>
);
Each task has an id (used in logs and status checks) and an async function child.

Agent Tasks

An agent task invokes an AI agent by passing a string prompt as the child and an agent prop. The agent runs in the same sandboxed environment and can read/write files, execute commands, and interact with the JJHub API:
import { Workflow, Task, on } from "@jjhub-ai/workflow";
import { ToolLoopAgent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { $ } from "bun";

const reviewer = new ToolLoopAgent({
  model: anthropic("claude-sonnet-4-5-20250929"),
  instructions: "You are a code reviewer. Check for bugs, suggest improvements, and verify test coverage.",
});

export default (ctx) => (
  <Workflow
    name="AI Review"
    triggers={[on.landingRequest.opened()]}
  >
    <Task id="test">
      {async () => {
        await $`bun test`;
      }}
    </Task>
    <Task id="review" agent={reviewer}>
      Review this landing request. Check for bugs, suggest improvements, and verify test coverage.
    </Task>
  </Workflow>
);
To use agent tasks, configure your AI API key as a repository secret:
printf %s "sk-ant-..." | jjhub secret set ANTHROPIC_API_KEY --body-stdin

Schema-Driven Tasks

For AI workflows where tasks pass structured data to each other, use createSmithers to define Zod output schemas. This gives you type-safe ctx.output() and automatic persistence:
import { createSmithers, Task, on } from "@jjhub-ai/workflow";
import { ToolLoopAgent as Agent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

const { Workflow, smithers } = createSmithers({
  analysis: z.object({
    summary: z.string(),
    severity: z.enum(["low", "medium", "high"]),
  }),
  fix: z.object({
    patch: z.string(),
    explanation: z.string(),
  }),
});

const analyst = new Agent({
  model: anthropic("claude-sonnet-4-5-20250929"),
  instructions: "You are a code analyst. Return structured JSON.",
});

const fixer = new Agent({
  model: anthropic("claude-sonnet-4-5-20250929"),
  instructions: "You are a senior engineer who writes minimal, correct fixes.",
});

export default smithers((ctx) => (
  <Workflow
    name="Bugfix"
    triggers={[on.manualDispatch({ inputs: { description: { type: "string", required: true } } })]}
  >
    <Task id="analyze" output="analysis" agent={analyst}>
      {`Analyze this bug: ${ctx.input.description}`}
    </Task>
    <Task id="fix" output="fix" agent={fixer}>
      {`Fix this issue: ${ctx.output("analysis", { nodeId: "analyze" }).summary}`}
    </Task>
  </Workflow>
));
Key differences from compute tasks:
AspectCompute TaskSchema-Driven Task
Exportexport default (ctx) => ...export default smithers((ctx) => ...)
Workflow/TaskImported directly from @jjhub-ai/workflowDestructured from createSmithers()
OutputNone (side effects only)Typed Zod schema via output="name"
Data passingNot supportedctx.output("name", { nodeId: "id" })
Use the simple compute pattern for CI tasks (lint, test, build, deploy). Use createSmithers when AI agents need to pass structured results between tasks.

Parallel Tasks

Use <Parallel> to run tasks concurrently:
import { Workflow, Task, Parallel, on } from "@jjhub-ai/workflow";
import { $ } from "bun";

export default (ctx) => (
  <Workflow
    name="CI"
    triggers={[on.push({ bookmarks: ["main"] })]}
  >
    <Task id="install">
      {async () => {
        await $`bun install`;
      }}
    </Task>
    <Parallel>
      <Task id="lint">
        {async () => {
          await $`bun run lint`;
        }}
      </Task>
      <Task id="typecheck">
        {async () => {
          await $`bun run typecheck`;
        }}
      </Task>
      <Task id="test">
        {async () => {
          await $`bun test`;
        }}
      </Task>
    </Parallel>
    <Task id="build">
      {async () => {
        await $`bun run build`;
      }}
    </Task>
  </Workflow>
);
Tasks inside <Parallel> start at the same time. The workflow continues to the next sibling only after all parallel tasks complete.

Context Object

Every workflow receives a ctx object via the function export:
PropertyDescription
ctx.inputManual dispatch inputs (empty object for other triggers)
ctx.repoRepository information (owner, name)
ctx.eventThe trigger event data
ctx.output(table, key)Access a previous task’s output
ctx.latest(table, nodeId)Latest iteration output (for Ralph loops)

Task Failure

If any task fails (non-zero exit code or thrown error), the workflow run is marked as failed and subsequent tasks are skipped. The commit status on the associated change is set to failure.

Secrets & Variables

Workflows often need credentials (API keys, deploy tokens) and configuration values. JJHub provides two mechanisms:

Secrets

Secrets are encrypted values injected as environment variables into workflow runs. They are never exposed in logs or API responses.
# Set a secret
printf %s "sk-ant-..." | jjhub secret set ANTHROPIC_API_KEY --body-stdin

# Set a deploy token
printf %s "your-deploy-token" | jjhub secret set DEPLOY_TOKEN --body-stdin

# List secrets (names only, values are hidden)
jjhub secret list

# Delete a secret
jjhub secret delete DEPLOY_TOKEN
Access secrets in workflow tasks via environment variables:
<Task id="deploy">
  {async () => {
    // Secrets are available as environment variables
    await $`./scripts/deploy.ts`;
    // deploy.ts can read process.env.DEPLOY_TOKEN
  }}
</Task>
Secrets are encrypted at rest and only injected into workflow runs. They are never visible in API responses, CLI output, or logs. If a workflow task prints a secret value to stdout, JJHub automatically redacts it in the log output.

Variables

Variables are non-secret configuration values. Unlike secrets, variable values are visible in API responses and CLI output.
# Set a variable
jjhub variable set NODE_VERSION -b "20"

# Get a variable value
jjhub variable get NODE_VERSION

# List variables
jjhub variable list

# Delete a variable
jjhub variable delete NODE_VERSION
Variables are also injected as environment variables into workflow runs.

Secrets vs Variables

AspectSecretsVariables
Values visible in API/CLINo (names only)Yes
Encrypted at restYesNo
Redacted in logsYesNo
Use caseAPI keys, tokens, passwordsBuild config, feature flags

Workflow Examples

CI Pipeline

A standard CI workflow that runs on pushes and landing requests:
// .jjhub/workflows/ci.tsx
import { Workflow, Task, Parallel, on } from "@jjhub-ai/workflow";
import { $ } from "bun";

export default (ctx) => (
  <Workflow
    name="CI"
    triggers={[
      on.push({ bookmarks: ["main"] }),
      on.landingRequest.opened(),
    ]}
  >
    <Task id="install">
      {async () => {
        await $`bun install`;
      }}
    </Task>
    <Parallel>
      <Task id="lint">
        {async () => {
          await $`bun run lint`;
        }}
      </Task>
      <Task id="typecheck">
        {async () => {
          await $`bun run typecheck`;
        }}
      </Task>
      <Task id="test">
        {async () => {
          await $`bun test`;
        }}
      </Task>
    </Parallel>
    <Task id="build">
      {async () => {
        await $`bun run build`;
      }}
    </Task>
  </Workflow>
);

Deploy Workflow with Inputs

A deploy workflow triggered manually with environment selection:
// .jjhub/workflows/deploy.tsx
import { Workflow, Task, on } from "@jjhub-ai/workflow";
import { $ } from "bun";

export default (ctx) => (
  <Workflow
    name="Deploy"
    triggers={[
      on.manualDispatch({
        inputs: {
          environment: {
            type: "choice",
            required: true,
            default: "staging",
            options: ["staging", "production"],
            description: "Target environment",
          },
          version: {
            type: "string",
            required: true,
            description: "Version to deploy (e.g., 1.2.3)",
          },
        },
      }),
    ]}
  >
    <Task id="deploy">
      {async () => {
        const env = ctx.input.environment;
        const version = ctx.input.version;
        await $`./scripts/deploy.ts --env ${env} --version ${version}`;
      }}
    </Task>
  </Workflow>
);
jjhub workflow run deploy -i environment=production -i version=1.2.3

AI Issue Triage

Automatically label and assign new issues using an AI agent:
// .jjhub/workflows/triage.tsx
import { Workflow, Task, on } from "@jjhub-ai/workflow";
import { ToolLoopAgent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

const triager = new ToolLoopAgent({
  model: anthropic("claude-sonnet-4-5-20250929"),
  instructions:
    "You are an issue triager. Read issues, add labels, and assign to team members.",
});

export default (ctx) => (
  <Workflow
    name="Issue Triage"
    triggers={[on.issue.opened()]}
  >
    <Task id="triage" agent={triager}>
      Read this issue. Add appropriate labels (bug, enhancement, documentation, question).
      If it's a bug, assign to the on-call engineer.
      If it's a feature request, add the 'needs-discussion' label.
    </Task>
  </Workflow>
);

AI Code Review

Review landing requests with an AI agent after CI passes:
// .jjhub/workflows/ai-review.tsx
import { Workflow, Task, on } from "@jjhub-ai/workflow";
import { ToolLoopAgent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { $ } from "bun";

const reviewer = new ToolLoopAgent({
  model: anthropic("claude-sonnet-4-5-20250929"),
  instructions:
    "You are a thorough code reviewer. Check for bugs, security issues, and suggest improvements.",
});

export default (ctx) => (
  <Workflow
    name="AI Code Review"
    triggers={[on.landingRequest.opened()]}
  >
    <Task id="test">
      {async () => {
        await $`bun test`;
      }}
    </Task>
    <Task id="review" agent={reviewer}>
      Review this landing request thoroughly. Check for:
      - Bugs and edge cases
      - Missing error handling
      - Test coverage gaps
      - Security concerns
      Suggest specific code improvements using suggestion blocks.
    </Task>
  </Workflow>
);

Programmable Landing Queue

Customize the landing queue behavior with a workflow:
// .jjhub/workflows/landing-queue.tsx
import { Workflow, Task, on } from "@jjhub-ai/workflow";
import { $ } from "bun";

export default (ctx) => (
  <Workflow
    name="Landing Queue"
    triggers={[on.landingRequest.readyToLand()]}
  >
    <Task id="validate">
      {async () => {
        await $`bun test`;
        await $`bun run build`;
      }}
    </Task>
  </Workflow>
);

Nightly Dependency Check

Scan for outdated or vulnerable dependencies on a schedule:
// .jjhub/workflows/deps.tsx
import { Workflow, Task, on } from "@jjhub-ai/workflow";
import { ToolLoopAgent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { $ } from "bun";

const scanner = new ToolLoopAgent({
  model: anthropic("claude-sonnet-4-5-20250929"),
  instructions:
    "You are a security scanner. Report vulnerabilities found in dependencies.",
});

export default (ctx) => (
  <Workflow
    name="Dependency Check"
    triggers={[on.schedule("0 0 * * *")]}
  >
    <Task id="outdated">
      {async () => {
        await $`bun outdated`;
      }}
    </Task>
    <Task id="security" agent={scanner}>
      Scan dependencies for known security vulnerabilities.
      If critical vulnerabilities are found, create an issue with details and remediation steps.
    </Task>
  </Workflow>
);

Chained Build & Deploy

Chain workflows using on.workflowRun() — build after CI passes, deploy after build succeeds:
// .jjhub/workflows/build.tsx
import { Workflow, Task, Parallel, on } from "@jjhub-ai/workflow";
import { $ } from "bun";

export default (ctx) => (
  <Workflow
    name="Build"
    triggers={[on.workflowRun({ workflows: ["ci"], types: ["completed"] })]}
  >
    <Parallel>
      <Task id="build-api">
        {async () => {
          await $`docker build -f cmd/server/Dockerfile -t my-registry/api:latest .`;
        }}
      </Task>
      <Task id="build-cli">
        {async () => {
          await $`cargo build --release -p my-cli`;
        }}
      </Task>
    </Parallel>
  </Workflow>
);

Combined CI + AI Workflow

A single workflow that runs tests and invokes an AI agent to analyze failures:
// .jjhub/workflows/smart-ci.tsx
import { Workflow, Task, Branch, on } from "@jjhub-ai/workflow";
import { ToolLoopAgent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { $ } from "bun";

const debugger_ = new ToolLoopAgent({
  model: anthropic("claude-sonnet-4-5-20250929"),
  instructions:
    "You are a CI failure analyst. Read test output, identify root causes, and suggest fixes.",
});

export default (ctx) => (
  <Workflow
    name="Smart CI"
    triggers={[
      on.push({ bookmarks: ["main"] }),
      on.landingRequest.opened(),
    ]}
  >
    <Task id="test">
      {async () => {
        await $`bun test`;
      }}
    </Task>
    <Task id="analyze-failure" agent={debugger_}>
      The CI tests just failed. Read the test output, identify the root cause,
      and either fix the issue by creating a new change or explain what went wrong.
    </Task>
  </Workflow>
);

Managing Workflows

List Workflows

jjhub workflow list

Trigger a Workflow

# Trigger manually
jjhub workflow run ci --ref main

# Trigger with inputs
jjhub workflow run deploy -i environment=staging -i version=1.0.0

View Runs

# List runs for a workflow
jjhub run list ci

# View a specific run
jjhub run view 42

# Watch a run in real-time (SSE streaming)
jjhub run watch 42

# Re-run a failed workflow
jjhub run rerun 42

Commit Statuses

Workflow runs automatically create commit statuses on the associated changes. You can also manage statuses directly:
# List statuses for a change
jjhub status list <change-id>

# Set a status manually
jjhub status set <sha> --state success --context ci/test

API Endpoints

MethodEndpointDescription
GET/api/repos/:owner/:repo/workflowsList workflows
POST/api/repos/:owner/:repo/workflows/:id/dispatchesTrigger manual dispatch
GET/api/repos/:owner/:repo/runsList workflow runs
GET/api/repos/:owner/:repo/runs/:idGet run details
GET/api/repos/:owner/:repo/runs/:id/logsStream run logs (SSE)
POST/api/repos/:owner/:repo/runs/:id/cancelCancel a run
POST/api/repos/:owner/:repo/runs/:id/rerunRe-run a workflow

Next Steps