Skip to content

Slack ​

A factory talks to Slack through a Slack app that its hub holds and assigns to it. The app always posts as its own bot. The hub receives the app's events and hands the factory its bot token, so the factory's environment holds no Slack token. This page connects the app to the factory and shows how workflows use it.

1. Set up the app in the hub ​

Create the Slack app, install it in your workspace, name that installation, such as slack-acme, invite its bot to the channels the factory should hear, and assign the app to the factory: see Slack app. The bot only sees channels it is a member of, and jigs never reads direct messages.

2. Use it from the factory ​

Nothing about the app goes in jigs.config.ts. Every Slack trigger, step and routine takes the installationName it acts through, so a factory assigned several Slack apps, even in one workspace, always says which bot it means. The hub keeps the events that arrive while the service is down.

A workflow that uses Slack declares it:

ts
import type { WorkflowDefinition } from "@jigs-ai/jigs";

const requires = {
  integrations: ["slack"],
} satisfies WorkflowDefinition["requires"];

Run pnpm exec jigs up to rebuild and restart the service.

Start runs from messages ​

An event trigger starts one run per message. Pick one of two sources:

SourceStarts a run for
slack.messages({ installationName, channels })Every top-level message in the channels.
slack.mentions({ installationName, channels })Only top-level messages that mention the bot, such as @<name>'s jigs.

List channels by ID, such as C0123ABCD, not by name. Slack shows a channel's ID at the bottom of its details. The installation's bot must be a member of each one.

ts
// jigs.config.ts
import { defineFactory, slack } from "@jigs-ai/jigs";

export default defineFactory({
  hub: { url: "https://hub.example.com" },
  workflows: { answer: () => import("./workflows/answer/answer.ts") },
  triggers: {
    "answer-questions": {
      active: process.env.ANSWER_QUESTIONS_ACTIVE === "true",
      workflow: "answer",
      source: slack.mentions({ installationName: "slack-acme", channels: ["C0123ABCD"] }),
    },
  },
});

Each run gets the inputs { installationName, channel, ts }: the trigger's installation and the message's channel and timestamp, merged over the trigger's own inputs. They are a reference: the run reads the message itself. jigs status shows them as slack <channel> <ts> from the moment the run starts, so you can tell which post a run is for. The workflow's inputs must accept them:

ts
// workflows/answer/answer.ts
import { defineWorkflow, type WorkflowInputs } from "@jigs-ai/jigs";
import { z } from "zod";

const inputs = z.object({ installationName: z.string(), channel: z.string(), ts: z.string() });

export async function answer(input: WorkflowInputs<typeof inputs>) {
  "use workflow";
  return { channel: input.channel, ts: input.ts };
}

export default defineWorkflow({
  inputs,
  requires: { integrations: ["slack"] },
  workflow: answer,
});

Every new top-level post counts: from a person, from another bot or app (such as a Workflow Builder announcement), with or without files. The installation's own bot's posts never start a run, and neither do thread replies (even one also sent to the channel), edits, deletes, joins, topic changes or other channel events. Direct messages are never read.

A trigger takes only events from its own installation. When two bots share a channel, each sees the same message, but only the trigger on the installation the event came through starts a run. An installation with no name on the hub starts no runs; once named, the events it received meanwhile that the factory has not yet collected start them.

Each message starts at most one run, however often Slack sends it again. A new trigger starts with messages posted after the service first runs it. After the service was down, it starts runs only for messages from the last 60 minutes; set the trigger's lookbackMinutes to change that. At most 20 of a trigger's runs are active at once, and later messages wait their turn; set maxActive to change that.

Read a message ​

fetchSlackMessage({ installationName, channel, ts }) reads a message, its permalink and its thread's replies, oldest first. Each post names its author, with their display name, their email, whether they are a bot, and isOwnBot for the installation's own bot. ts must be a top-level message; a reply's ts reads as gone. A deleted message reads as { gone: true }, so the workflow can end quietly:

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

export async function readMessage(installationName: string, channel: string, ts: string) {
  const message = await fetchSlackMessage({ installationName, channel, ts });
  if (message.gone) return null;
  return `${message.author.name}: ${message.text}`;
}

Like every step, a read is recorded, so a resumed run sees the same message it saw before. Read again to see what changed.

Post a message ​

postSlackMessage({ installationName, channel, text, threadTs }) posts plain Slack mrkdwn text as the installation's bot. With threadTs it replies in the thread under that message; without it, it posts a new message in the channel. It returns the new message's ts.

A post is tried once. If Slack's answer is lost, the step fails rather than risk posting twice.

Wait for a reply ​

waitForSlackReply({ installationName, channel, threadTs, lastRead }) parks the run until someone replies in the thread under threadTs, and ends replied with every reply posted after lastRead, oldest first. threadTs must be the thread's top-level message, never a reply.

lastRead is the ts of the newest post the workflow has read: usually the last reply in its latest fetchSlackMessage, or threadTs when the thread had no replies. Never pass the question the workflow just posted, or a reply posted while the run was working is missed. A reply already in the thread returns at once, so it may not answer the question just asked.

The result's outcome says how the wait ended. With replied, replies is never empty, and each reply has its text and author. The outcome is timed-out instead when until passes, and gone when the thread's top-level message was deleted, before or during the wait:

ts
import { waitForSlackReply } from "#jigs/routines";
import { fetchSlackMessage, postSlackMessage } from "#jigs/steps";

export async function askInThread(
  installationName: string,
  channel: string,
  ts: string,
  question: string,
) {
  const thread = await fetchSlackMessage({ installationName, channel, ts });
  if (thread.gone) return "the message was deleted";
  const lastRead = thread.replies.at(-1)?.ts ?? ts;
  await postSlackMessage({ installationName, channel, threadTs: ts, text: question });
  const until = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
  const result = await waitForSlackReply({
    installationName,
    channel,
    threadTs: ts,
    lastRead,
    until,
  });
  if (result.outcome === "timed-out") return "no reply within a day";
  if (result.outcome === "gone") return "the message was deleted";
  return result.replies.map((reply) => `${reply.author.name}: ${reply.text}`).join("\n");
}

Any person's reply counts. Replies from bots, including the installation's own, never do. Without until, the wait has no time limit: it ends with a reply, or when you cancel the run with jigs cancel. With until, an ISO 8601 timestamp, it ends timed-out once that time passes with no reply. The thread is always read once first, so a reply already there still wins when until is in the past. A reply heard through the hub wakes the run within seconds, and jigs poke re-reads the thread at once. Only one run can wait on a thread at a time, per installation.

Call other Slack methods ​

callSlack(method, params, { installationName }) calls any Slack Web API method as the installation's bot and returns Slack's response. Use it to react to a message, update or pin one, or anything else jigs has no step for. Call it from your own "use step" function. Arguments that are not strings, such as blocks, are sent as JSON.

When Slack answers with an error, callSlack throws a SlackApiError whose code is Slack's error, such as already_reacted. A step can run more than once, so a call that is not safe to repeat should treat the error a repeat gets as success. Here a deleted message, message_not_found, is tolerated too:

ts
// workflows/deploys/steps.ts
import { callSlack, SlackApiError } from "@jigs-ai/jigs/steps/slack";

export async function addReaction(
  installationName: string,
  channel: string,
  timestamp: string,
  name: string,
) {
  "use step";
  try {
    await callSlack("reactions.add", { channel, timestamp, name }, { installationName });
  } catch (error) {
    const tolerated = ["already_reacted", "message_not_found"];
    if (!(error instanceof SlackApiError && tolerated.includes(error.code))) throw error;
  }
}

Workflow code calls it like any step: await addReaction(installationName, channel, ts, "eyes").

A method may need a bot scope jigs does not use, such as pins:write for pins.add. Add it to the app's bot scopes in the hub and install the app again. Until then, the call fails with a SlackApiError whose code is missing_scope.

Example: answer questions in a channel ​

This workflow starts for each message that mentions the bot. A Jev decision model decides whether it is a question about the codebase, and whether it is clear enough to answer. If not clear, the workflow asks in the thread and waits. Then an agent researches the code and the answer is posted in the thread.

ts
// workflows/answer/answer.ts
import { defineWorkflow, harnesses, models, type WorkflowInputs, yesNo } from "@jigs-ai/jigs";
import { z } from "zod";
import { askJev, runAgent, waitForSlackReply } from "#jigs/routines";
import { fetchSlackMessage, postSlackMessage, provisionWorktree } from "#jigs/steps";

const inputs = z.object({ installationName: z.string(), channel: z.string(), ts: z.string() });
const agents = { researcher: harnesses.claude({ model: "sonnet" }) };
const decisionModel = models.openrouter("typesafe/jev-1.13");

export async function answer(input: WorkflowInputs<typeof inputs>) {
  "use workflow";
  const { installationName, channel, ts } = input;
  const message = await fetchSlackMessage({ installationName, channel, ts });
  if (message.gone) return { answered: false };

  const { answers } = await askJev({
    model: decisionModel,
    state: { message: message.text },
    questions: {
      aboutCode: yesNo("Is this a question about the codebase?"),
      clear: yesNo("Is it clear enough to answer without asking anything back?"),
    },
  });
  if (answers.aboutCode.probability < 0.7) return { answered: false };

  let question = message.text;
  if (answers.clear.probability < 0.5) {
    const lastRead = message.replies.at(-1)?.ts ?? ts;
    await postSlackMessage({
      installationName,
      channel,
      threadTs: ts,
      text: "Which part of the codebase do you mean?",
    });
    const until = new Date(Date.now() + 60 * 60 * 1000).toISOString();
    const result = await waitForSlackReply({
      installationName,
      channel,
      threadTs: ts,
      lastRead,
      until,
    });
    if (result.outcome !== "replied") return { answered: false };
    for (const reply of result.replies) {
      question += `\n\n${reply.author.name} added: ${reply.text}`;
    }
  }

  const worktree = await provisionWorktree({ binding: "app", branch: `answer/${input.triggerId}` });
  const research = await runAgent({
    harness: agents.researcher,
    cwd: worktree.path,
    prompt: `Answer this question about the code. Do not change files.\n\n${question}`,
  });
  await postSlackMessage({ installationName, channel, threadTs: ts, text: research.text });
  return { answered: true };
}

export default defineWorkflow({
  inputs,
  requires: { agents, bindings: ["app"], integrations: ["slack"], models: [decisionModel] },
  workflow: answer,
});

Register it with the slack.mentions trigger from Start runs from messages. The decision model needs OPENROUTER_API_KEY in the factory's environment; see Models and harnesses.

Checks ​

jigs doctor, and jigs up, check that each Slack installation the factory's triggers name is named and assigned to it in the hub, and that every named Slack installation assigned to it granted every scope jigs uses. Each failure names what to fix in the hub.

Preflight does not sweep Slack installations: a step names its installation when it runs, and a missing scope fails that step with Slack's missing_scope.