Skip to content

Run a hub ​

A hub is one service that receives every GitHub, Linear, Slack and PagerDuty event for your factories and holds every credential they use. A factory never needs a public URL or a provider secret: it asks its hub for what happened and for short-lived tokens, and the hub answers only for the apps you assigned to it.

One hub serves an Organization: its members, its apps on each provider and its factories. A factory needs a hub once it uses any of these providers, and not before. A single person running one factory on a laptop runs a hub too, usually on the same machine.

text
GitHub, Linear, Slack, PagerDuty ──▶ hub (public URL) ◀── factories ask for events and tokens

The hub keeps each factory's provider events in order until the factory confirms them, so nothing is lost while a factory's service is down. A factory that stays away longer than the retention loses the events older than it; the hub tells the factory, and every waiting run checks its provider again. While the hub itself is down, providers cannot reach it and runs that wait on them do not progress.

Run the hub at the same version as your factories' @jigs-ai/jigs, and upgrade them together.

What you need ​

  • Node.js 24.22.1 or newer, or Docker to run the hub in a container.
  • A Postgres database for the hub alone, never a factory's.
  • One public HTTPS address, such as https://hub.example.com, that GitHub, Linear, Slack and PagerDuty can reach, and that you and your factories open. A Tailscale Funnel gives you one without a server.
  • A sign-in app on GitHub, described in First sign-in.

Two kinds of GitHub app ​

A hub uses GitHub in two separate ways, through two different kinds of app. Keep them apart: each has its own settings page on GitHub and its own URLs.

Sign-in appGitHub App
What it doesLets people sign in to the hub's web pages.Lets factories act on GitHub, and sends the hub GitHub's webhooks.
How manyExactly one per hub.One or more, added once the hub runs.
Created underDeveloper settings → OAuth AppsDeveloper settings → GitHub Apps
Its credentials goIn the hub's environment, as HUB_SIGN_IN_GITHUB_CLIENT_ID and HUB_SIGN_IN_GITHUB_CLIENT_SECRET.Into the hub's web pages, under Apps → Add app → GitHub App.
URLs it needsHomepage URL and Redirect URI, which the hub prints.Webhook URL and Setup URL, which the App's page on the hub shows.
Set it upFirst sign-inGitHub App

The sign-in app never receives webhooks, and a GitHub App's Webhook URL never goes in the sign-in app's Redirect URI.

Settings ​

The hub reads its settings from the environment when it starts, and refuses to start with a message naming every missing or bad value.

VariableDefaultMeaning
HUB_PUBLIC_URLrequiredThe one address people, providers and factories reach the hub at. It must be a bare origin, such as https://hub.example.com, with no path. Open the web app at this address: it refuses forms sent from any other.
HUB_DATABASE_URLrequiredThe Postgres connection URL of the hub's database.
HUB_ENCRYPTION_KEYrequired32 random bytes in base64, from openssl rand -base64 32. Every secret the hub stores is encrypted with it, and sign-in sessions are signed with a key made from it.
HUB_SIGN_IN_GITHUB_CLIENT_IDrequiredThe client ID of the sign-in app.
HUB_SIGN_IN_GITHUB_CLIENT_SECRETrequiredThe sign-in app's client secret.
HUB_ADMIN_EMAILrequiredThe email of the person who creates the Organization: the first to sign in with a GitHub account whose sign-in email is this one.
HUB_RETENTION_DAYS7How many days the hub keeps a factory's provider events, confirmed or not.
HOST127.0.0.1The address the hub listens on. Set 0.0.0.0 in a container.
PORT3000The port the hub listens on.

Keep HUB_ENCRYPTION_KEY safe and never change it. Without it, the hub cannot read any app secret, Linear or Slack token it holds, and you would add every app and connect every workspace again.

The hub applies its database migrations each time it starts. It stops cleanly on SIGTERM, so any process manager can restart it.

Start the hub ​

The hub is published on npm as @jigs-ai/hub, with a jigs-hub command. Start a Postgres for it first, for example in Docker:

sh
docker run -d --name jigs-hub-postgres --restart unless-stopped \
  -e POSTGRES_USER=hub -e POSTGRES_PASSWORD=hub -e POSTGRES_DB=hub \
  -p 127.0.0.1:5490:5432 -v jigs-hub-postgres:/var/lib/postgresql/data \
  postgres:17

Put the settings in a file, such as hub.env, and start the hub with them, pinned to your factories' jigs version:

sh
# hub.env
HUB_PUBLIC_URL=https://hub.example.com
HUB_DATABASE_URL=postgres://hub:hub@127.0.0.1:5490/hub
HUB_ENCRYPTION_KEY=...
HUB_SIGN_IN_GITHUB_CLIENT_ID=...
HUB_SIGN_IN_GITHUB_CLIENT_SECRET=...
HUB_ADMIN_EMAIL=you@example.com
sh
set -a; . ./hub.env; set +a
npx @jigs-ai/hub@<version>

It prints where it listens and the two URLs the sign-in app needs:

text
hub listening on http://127.0.0.1:3000
sign-in app Homepage URL: https://hub.example.com
sign-in app Redirect URI: https://hub.example.com/api/auth/callback/github

Open HUB_PUBLIC_URL to sign in.

For a local trial, HUB_PUBLIC_URL=http://127.0.0.1:3000 works for signing in and adding factories, but providers cannot reach it: no webhooks arrive, and some providers refuse a plain http OAuth redirect, until the hub has a public https address.

A proxy in front of the hub, such as Funnel or a load balancer, must connect from the same machine or a private network address. The hub limits sign-in attempts per client, and it reads the client's address from the proxy's X-Forwarded-For header only when the proxy connects from one of those.

Tailscale Funnel ​

Tailscale Funnel publishes a port on your machine at a public HTTPS address on your tailnet's domain. With the hub on port 3000:

sh
tailscale funnel --bg 3000

Funnel prints the address, such as https://laptop.example-tailnet.ts.net. Set HUB_PUBLIC_URL to it and start the hub. The hub can keep listening on 127.0.0.1, since Funnel connects from the same machine.

Docker image ​

There is no published image. Build your own from the npm package, pinned to an exact version so a rebuild never upgrades the hub by surprise:

dockerfile
FROM node:24-slim
RUN npm install --global @jigs-ai/hub@<version>
ENV HOST=0.0.0.0 PORT=3000
EXPOSE 3000
USER node
CMD ["jigs-hub"]
sh
docker build -t jigs-hub .
docker run -d --name jigs-hub --restart unless-stopped \
  --env-file hub.env -p 3000:3000 jigs-hub

HUB_DATABASE_URL must name a Postgres the container can reach, which is not 127.0.0.1 on the host. Put the hub and its Postgres on one Docker network and use the Postgres container's name as the host.

First sign-in ​

People sign in to the hub with GitHub, through the hub's sign-in app: a GitHub OAuth App, separate from the GitHub Apps your factories act as. Create it on GitHub under Settings → Developer settings → OAuth Apps → New OAuth App, or under your organization's Developer settings, and set:

  • Homepage URL: HUB_PUBLIC_URL.
  • Redirect URI, under Redirect URIs: HUB_PUBLIC_URL followed by /api/auth/callback/github, such as https://hub.example.com/api/auth/callback/github.
  • Leave Allow wildcard matching and Enable Device Flow off. Expire user access tokens doesn't matter: the hub uses GitHub only to learn who signs in.

You don't need to assemble these by hand. The hub prints both URLs each time it starts, and also when it refuses to start because the sign-in app's client ID or secret is not set yet, so you can start it once without them to see what to enter.

Generate a client secret, and set the client ID and secret as HUB_SIGN_IN_GITHUB_CLIENT_ID and HUB_SIGN_IN_GITHUB_CLIENT_SECRET.

The hub knows a person by their GitHub account's sign-in email: its public email, or its primary email when it shows none publicly. That email must be verified on GitHub, or the hub refuses the sign-in.

Then open the hub and choose Sign in with GitHub with the account whose sign-in email is HUB_ADMIN_EMAIL. The hub asks you to name the Organization and makes you its first admin. Only one Organization is created this way; after that, the hub is invite-only.

Members ​

Under Members, an admin chooses Invite member and enters a person's GitHub account sign-in email, as an admin or a member. The hub sends no email: it shows an invite link to copy and send yourself, which works once. The person opens the link and signs in with the GitHub account whose sign-in email that is. Until then the invite waits under Pending invites, where an admin can copy its link again or revoke it.

Admins manage apps, members, settings and every factory: they add, rename, describe and remove apps, name installations, invite people, change roles and change any factory. Members can see everything, and add and manage their own factories: rename them, re-issue their tokens, connect and disconnect their apps, and remove them. A member who leaves the Organization loses those rights while they are out, though their factories still show them as the one who added them. Only admins can change a factory with no recorded creator, such as one added before the hub recorded who added factories.

The hub's home page charts, hour by hour over the last 24 hours, the webhooks it received and the events it queued for your factories, with a total for each provider.

Add a factory ​

Under Factories, any member chooses Add factory and names it. The hub shows two lines to copy, the second with the factory's token, once:

ts
// Inside defineFactory({ ... }) in jigs.config.ts
hub: { url: "https://hub.example.com" },
sh
# .env.local
JIGS_HUB_TOKEN=<token>

Add the first to the factory's jigs.config.ts, and set the second in the environment of the copy that will use the token, usually its .env.local; see the environment. Then run pnpm exec jigs up. The page checks every few seconds and says once the factory has connected.

If the token is lost, Re-issue token makes a new one, shows it the same way and stops the old one at once. Remove deletes the factory and every event waiting for it.

Each running copy that connects needs its own factory on the hub, with its own token. Two copies with one token split its events between them, so each misses some. See Developing in worktrees. A copy with no active trigger, whose workflows and bindings use none of these providers, needs no token.

Factories opens on My factories, the ones you added; All factories lists every factory in the Organization. Both show when each factory last reached the hub, with a green dot while it is connected, its jigs version, how many events it has not confirmed, how many apps it is assigned and who added it. A factory's own page has two tabs. Settings lists its connected apps, with their installations' names and when the factory last received an event from each, and lets whoever added the factory, or an admin, connect and disconnect apps, rename the factory, which keeps its token, re-issue its token or remove it. Activity is its event log, newest first: every provider event the hub kept for it, with when it arrived and whether the factory has confirmed it. An event marked hub is one the hub made from what it read on the provider, where the provider sends no webhook, such as a Linear agent session someone archived. Select an event to see its payload, and choose Load older events to see further back.

Add apps and assign them ​

An app is your Organization's own identity on a provider: a GitHub App, a Linear OAuth app, a Slack app or a PagerDuty app. You create each one by hand on the provider, then add it under Apps → Add app. Its page on the hub shows the exact URLs and settings to put back on the provider, and installs or connects it. An admin can rename an app on its page, and give it a short description, which only people on the hub see.

Then, on each factory's Settings tab, choose Connect app and connect the app. That is an assignment: a factory receives events from, and gets tokens for, only the apps assigned to it. An app can be assigned to several factories, and every one of them receives its events; its page lists them.

A factory may be assigned several installations of one provider, including several apps in one workspace or GitHub organization. It names the installation it means everywhere: in each binding, each trigger, each step and each agent. jigs never picks one for it.

Name each installation ​

Each place an app is installed or connected, such as a GitHub account or a Slack workspace, is an installation, and each needs an installation name: the name factories use to say which installation they mean, such as github-acme or slack-support. A name is lowercase letters, digits and hyphens, starting with a letter, and no two installations in your Organization share one.

The hub learns of some installations by itself, such as those a GitHub App already had when you added it, so a new installation starts without a name. The app's page shows it as Needs a name; enter one under Installation name and save. You can rename one later, but every factory that uses the old name then needs the new one.

An unnamed installation gives no factory a token, and its events start and wake no runs. Once you name it, the events it received before then that a factory has not yet collected carry the new name.

Example: one Slack app per teammate ​

Alice and Bob each run their own factory against the same Slack workspace, and each wants replies from their own bot. Each creates a Slack app, adds it to the hub and installs it in the workspace. Alice names her installation slack-alice and assigns her app only to her factory; Bob does the same with slack-bob.

Alice's factory uses that name in its triggers:

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

// In defineFactory's `triggers`.
const triggers = {
  "answer-questions": {
    active: process.env.ANSWER_QUESTIONS_ACTIVE === "true",
    workflow: "answer",
    source: slack.mentions({ installationName: "slack-alice", channels: ["C0123ABCD"] }),
  },
};

and in its steps:

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

export async function reply(channel: string, threadTs: string, text: string) {
  await postSlackMessage({ installationName: "slack-alice", channel, threadTs, text });
}

Both bots see a message in a channel they share, but a trigger takes only events from its own installation, so a message mentioning Alice's bot starts one run, in Alice's factory.

Check the factory ​

pnpm exec jigs doctor, for each provider something in the factory uses, checks that the copy reaches the hub, then:

  • that each installation name the factory uses, in its bindings, triggers and agents, is named and assigned to it;
  • that each binding's installation reaches its repository;
  • that every named installation assigned to it works: each Slack one granted the scopes jigs uses, each PagerDuty one can read incidents, and each Linear one has your linear.operator as a user.

It names what to fix in the hub when one fails. A copy with no hub connection runs none of these checks.

Before a run starts, preflight checks only the installations the workflow's agents name. One that a step names, or that comes from the run's inputs, is checked when the step first uses it: the hub refuses a name it does not know.