Skip to content

Build a workflow ​

This page builds one workflow from start to finish: triage takes a bug report, has an agent investigate it in a repository, and asks a model to turn the findings into a structured verdict. It assumes a running factory from Install and run a first workflow.

1. Connect a repository ​

The agent needs a repository to work in. Binding one needs GitHub credentials, so set them first: see GitHub identity. Then bind the repository and bring the factory up so the service clones it:

sh
pnpm exec jigs bind git@github.com:owner/app.git
pnpm exec jigs up

The binding's name comes from the repository name, here app. A workflow that needs no repository can skip this and use createRunDirectory() from #jigs/steps for a scratch directory instead, as hello does.

2. Write the workflow ​

Create workflows/triage/triage.ts:

ts
import { defineWorkflow, harnesses, JigsError, models, type WorkflowInputs } from "@jigs-ai/jigs";
import { z } from "zod";
import { askModel, runAgent } from "#jigs/routines";
import { provisionWorktree } from "#jigs/steps";

const inputs = z.object({
  binding: z.string().default("app"),
  report: z.string().min(1),
});

const agents = { investigator: harnesses.claude({ model: "sonnet" }) };
const summarizer = models.openrouter("google/gemini-2.5-flash-lite");

const verdict = z.object({
  reproducible: z.boolean(),
  severity: z.enum(["low", "medium", "high"]),
  summary: z.string(),
});

export async function triage(input: WorkflowInputs<typeof inputs>) {
  "use workflow";

  const worktree = await provisionWorktree({
    binding: input.binding,
    branch: `triage/${input.triggerId}`,
  });

  const investigation = await runAgent({
    harness: agents.investigator,
    cwd: worktree.path,
    prompt: `Investigate this bug report. Try to reproduce it and find the cause. Do not change files.\n\n${input.report}`,
  });

  const result = await askModel({
    model: summarizer,
    prompt: `Turn these findings into a triage verdict:\n\n${investigation.text}`,
    output: verdict,
  });

  if (!result.output.reproducible) {
    throw new JigsError(
      "the agent could not reproduce the report",
      "add steps to reproduce to the report and run triage again",
    );
  }
  return result.output;
}

export default defineWorkflow({
  inputs,
  requires: { agents, bindings: ["app"], models: [summarizer] },
  workflow: triage,
});

What each part does:

  • inputs is a zod schema. jigs run checks --input values against it before a run is created. jigs also adds triggerId, an ID unique to the run, which here gives each run its own branch.
  • "use workflow" marks the function as a durable workflow. Its body must be safe to replay, so all real work happens in the steps it calls.
  • agents names each agent the workflow runs, by the part it plays. A harness descriptor is plain data, so it can be passed to a step.
  • provisionWorktree cuts a worktree for this run from the binding's clone, on the branch you name. A resumed run gets the same worktree back.
  • runAgent runs an agent, here Claude Code, in that directory with its tools. result.text is its final answer. Pass output a zod schema to get a parsed result.output instead.
  • askModel calls a model API directly, with no tools and no directory. It suits summarizing and classifying text you already have. The output schema checks the answer's shape, not whether it is right.
  • JigsError ends the run as failed, with a hint for whoever reads it. Returning a value always means success.
  • defineWorkflow ties the function, its inputs and its requirements together, and makes TypeScript check the function's parameter against the schema. It is the file's default export.
  • requires lists what the workflow needs: its agents, the binding and the model source. jigs derives the harness CLIs to check from the agents. Preflight checks each one before every run and refuses to start with a repair when one is missing. jigs doctor runs the same checks.

This model source reads OPENROUTER_API_KEY from the factory's .env. See Models and harnesses for the other harnesses and sources and what each one needs.

3. Register it ​

Add the workflow to the workflows map in jigs.config.ts:

ts
workflows: {
  hello: () => import("./workflows/hello/hello.ts"),
  triage: () => import("./workflows/triage/triage.ts"),
},

The import stays deferred, so commands that only read configuration never load workflow code.

4. Run it ​

sh
pnpm exec jigs up
pnpm exec jigs run triage --input report="Saving a draft twice loses the title."
pnpm exec jigs watch

jigs up rebuilds the factory and restarts the service because the workflow changed. watch follows the run step by step; jigs status <run-id> shows the result when it finishes. On success the worktree is released automatically. See release to keep it instead.

Ask a person and wait ​

haltForHuman posts a question on a Linear ticket and suspends the run until someone replies there. It needs Linear credentials (see Linear identity) and a claimed ticket. Claiming also makes sure only one run works on a ticket at a time:

ts
import { claimTicket, haltForHuman } from "#jigs/routines";
import { resolveLinearIssue } from "#jigs/steps";

const issue = await resolveLinearIssue(input.ticket);
const claim = await claimTicket(issue.id, issue.identifier);

const reply = await haltForHuman(claim, {
  headline: "Triage needs a decision before it continues.",
  where: "triage",
  questions: [
    {
      question: "Should the fix include archived drafts?",
      options: [{ label: "Active drafts only" }, { label: "Include archived drafts" }],
    },
  ],
  onReply: "continue",
});
// reply.body is the person's answer, as free text.

Add ticket: z.string() to inputs and integrations: ["linear"] to requires. jigs status <run-id> shows the question and the link to answer it. The run notices a reply on its next check; jigs poke <run-id> checks now. Answer the existing run rather than starting another one.

Wait on a pull request ​

watchPullRequest yields the current GitHub facts immediately, then yields again when those facts change. The workflow decides what to do with them: run an agent, apply rules, or keep waiting. The watcher makes no model calls and does not decide whether a comment needs an answer.

This example continues a builder session created earlier in the workflow. pr identifies the pull request by owner, repo and number:

ts
import { watchPullRequest } from "#jigs/routines";

for await (const snapshot of watchPullRequest(pr)) {
  if (snapshot.state === "closed") return { merged: snapshot.merged };

  const situation = JSON.stringify(snapshot);
  await builder.run({
    resume: `Read this PR's discussion and checks. Address anything that needs
attention, or do nothing if it is already handled. You may respond on GitHub
and push fixes. Do not merge. Current facts: ${situation}`,
    fresh: `${task}

Continue work on PR #${pr.number} in ${pr.owner}/${pr.repo}.
Read the code and discussion, then respond or push fixes if needed. Do not merge.
Current facts: ${situation}`,
  });
}

The example's task and builder belong to the factory. It shows one agent invocation per update; see the recipe for checking the agent's work and retrying incomplete local changes within a factory-owned limit. See agent sessions for creating the builder and supplying recovery context in fresh.

snapshot.state and snapshot.merged come from GitHub. The snapshot also includes headSha, draft and merge state, labels, reviews, inline review threads and conversation comments. jigs summarizes GitHub checks and commit statuses as ci ("red", "green" or "pending") and includes failingChecks. These are observed facts, not an assessment that the work is finished.

To compare a fresh read with an earlier snapshot, import pullRequestSnapshotKey from @jigs-ai/jigs and compare their keys. It uses the same fact comparison as the watcher, ignoring collection ordering and incidental fetch metadata.

Repeated notifications with unchanged facts produce no new snapshot. An agent's own comments and pushes do change the facts and can produce another turn. An agent invocation that decides nothing needs doing is normal. The watcher has no hidden agent budget or conversation filter. The recipe bounds recovery attempts for each update, rather than limiting the total number of updates a PR can receive. Agents may post using their GitHub tools: no hidden jigs marker is required, and an unmarked comment does not automatically mean unresolved work.

The watcher yields a closed snapshot once, then ends. Leaving the loop by return, break or a throw releases the watch. It shares the existing PR hook with pullRequestGate: only one run can watch a given pull request at a time. A second owner receives a claim conflict. The service's polling, webhooks and jigs poke wake the watch to reread GitHub.

The watcher never merges. Factory code decides who may merge and calls mergePullRequest when appropriate; that step rechecks current GitHub facts and the merge approval policy. The linear-ticket-to-pr recipe demonstrates continuing the builder session after publication with this policy.

Use the rules-based gate ​

pullRequestGate is an alternative for workflows that want jigs to classify outstanding work using its marker rules. Loop over it with for await: each wake says what is outstanding right now.

ts
function pullRequestGate(
  pr: PullRequestRef,
  options: { scope: string; approval: MergePolicy["approval"]; worktree?: Worktree },
): AsyncIterable<PullRequestWake>;

type PullRequestWake =
  | { kind: "closed"; merged: boolean }
  | { kind: "merge-ready"; headSha: string; retryNoted: boolean }
  | { kind: "ci-red"; headSha: string; failing: CheckRun[]; mentionLogin: string | null }
  | { kind: "review-comments"; threads: ReviewThread[]; body?: string };
ts
import { postPullRequestNote, pullRequestGate } from "#jigs/routines";
import { mergePullRequest, resolveMergePolicy } from "#jigs/steps";

const merge = await resolveMergePolicy(input.binding);
const scope = `triage/${input.ticket}`;

const gate = pullRequestGate(pr, { scope, approval: merge.approval, worktree });
for await (const wake of gate) {
  if (wake.kind === "closed") return { merged: wake.merged };
  if (wake.kind === "merge-ready") {
    const result = await mergePullRequest(pr, wake.headSha, merge);
    if (result.merged) return { merged: true };
    await postPullRequestNote({
      pr,
      scope,
      reason: result.transient ? "merge-retry" : "merge",
      headSha: wake.headSha,
      body: `I could not merge this pull request: ${result.reason}.`,
    });
    continue;
  }
  // ci-red and review-comments: fix, push, answer the threads
}

Leaving the loop stops watching, whether by return, break or a throw. Only one run can watch a pull request at a time, so a second gate on the same pull request fails with a claim conflict.

The scope names this workflow's work on the pull request. Every comment jigs posts carries it in a hidden marker, and the gate reads those markers back to decide what is still outstanding. Keep the scope stable, so a later run recognises its own answers.

A wake is delivered only while its head is still the pull request's head. If the branch moved while you handled an earlier wake, a red build on the old commit is dropped rather than repaired twice.

Pass the worktree your workflow pushes from, and the gate also checks each red build and review wake against the local branch. A wake for an older commit of that branch is dropped: the run has moved past it, even if GitHub still reports it in the moment after a push. A wake for a commit the worktree does not have is delivered, because someone else pushed it and it still needs an answer.

postPullRequestNote posts once per commit and reason, so a merge you retry on every wake reports its refusal once.

Record what the workflow created ​

jigs status <run-id> lists a run's resources, such as its worktree. Record anything else a person may need to find with registerResource:

ts
import { registerResource } from "#jigs/steps";

await registerResource({
  kind: "s3-report",
  identity: "quarterly/2026-Q3",
  url: "https://reports.example.com/quarterly/2026-Q3",
});

The kind and identity together name the resource, so registering it again only updates its URL. When the thing you created cannot safely be created twice, create it in one step and register it in a separate call afterwards, so a retry repeats only the registration. A record is for finding things; it never permits jigs to delete them.

Explore further ​

Released under the MIT License.