KiCI

Use GitHub Actions minutes as burst capacity for your CI

A one-shot KiCI agent is a plain Node process that dials your orchestrator, runs one job and exits, so anything that can start a process can host one. GitHub Actions can: this post turns the Actions minutes you already have into burst capacity for KiCI jobs. You inherit GitHub's queue and limits, so it also covers which jobs belong there.

On this page

Bursting CI usually means booting machines you own

You want CI capacity that grows when the queue backs up and shrinks when it drains. Today you pick between two costs. Reserve the machines and you pay for the peak all month, even at 3am when nothing runs. Or wire up a cloud-provisioning integration: an API to boot a VM, credentials to inject, a lifecycle to babysit, a reaper to make sure a crash doesn’t leave an instance billing you for a week.

Both cost you the same assumption: bursting means booting a server you own and operate. That assumption is the expensive part.

A KiCI agent is a process, not a server

Here’s what a KiCI agent is: a plain Node process that opens one outbound WebSocket to your orchestrator and runs the jobs it’s handed. In one-shot mode it runs a single job and exits. It needs a Node runtime, a network path out, and nothing else. It does not care what started it.

So “add capacity” doesn’t have to mean “boot a VM.” It can mean “ask something that can already run a process to run one.” And you have systems that do exactly that, sitting idle between builds: your CI runners.

KiCI’s event scaler is built for this. It makes no cloud calls of its own. When demand rises it emits a reserved kici.scaler.scale-up event; when an agent should go away it emits kici.scaler.scale-down. You consume those in a provisioning workflow you write in TypeScript, with the same kiciEvent() trigger you’d use for anything else. The workflow-driven autoscaling guide covers the event contract. What comes up, and how, is the one thing left open on purpose.

GitHub Actions as the pool

The pool most teams already have is GitHub Actions. Its runner queue is elastic, a run is already an ephemeral, one-shot sandbox (the exact shape of a scale-to-zero agent), and the minutes are free up to your plan’s quota on private repos, and free outright on public repos using standard runners.

Here’s the provisioning workflow. It subscribes to the scale-up event and dispatches a kici-agent.yml run in a GitHub repo:

import {
  workflow,
  job,
  step,
  kiciEvent,
  SCALER_EVENT_NAMES,
  ScalerScaleUpPayload,
} from '@kici-dev/sdk';

/** Must match the `name:` of the `event` scaler in your `scalers.yaml`. */
const SCALER_NAME = 'github-actions';

/** The one-shot runner workflow in your runner repo, and the ref to dispatch. */
const GH_WORKFLOW = 'kici-agent.yml';
const GH_REF = 'main';

export default workflow('github-actions-autoscale-provision', {
  on: [kiciEvent({ name: SCALER_EVENT_NAMES.scaleUp, match: { '$.scalerName': SCALER_NAME } })],
  jobs: [
    job('provision', {
      runsOn: ['default'],
      // Binds the `github-actions` context, which carries both the dispatch
      // token and this integration's two settings. Load-bearing: the job option
      // is `context`, and an unrecognised key is DROPPED at compile time rather
      // than rejected — so a typo here fails at run time on an unresolved
      // secret, never at compile time.
      context: 'github-actions',
      steps: [
        step('dispatch', async (ctx) => {
          const p = ScalerScaleUpPayload.parse(ctx.rawPayload);

          // Your runner repo, as `owner/repo`. Set it once on the context:
          //   kici-admin variable set <orgId> github-actions \
          //     GITHUB_RUNNER_REPO --value myorg/ci-runners
          // or replace the fallback below in your own copy.
          const runnerRepo = ctx.env.GITHUB_RUNNER_REPO ?? 'myorg/ci-runners';

          // Optional. A release tag holding a `kici-admin agent package` tarball
          // the runner installs instead of the published npm agent. Leave it
          // unset unless you pin exact builds or your runners cannot reach npm.
          const agentBundleRelease = ctx.env.GITHUB_AGENT_BUNDLE_RELEASE;

          const inputs: Record<string, string> = {
            claim_code: p.claimCode,
            orchestrator_url: p.orchestratorUrl,
            agent_id: p.agentId,
            labels: p.labels.join(','),
          };
          if (agentBundleRelease) inputs.agent_bundle_release = agentBundleRelease;

          const token = await ctx.secrets.get('GITHUB_DISPATCH_TOKEN');
          const res = await fetch(
            `https://api.github.com/repos/${runnerRepo}/actions/workflows/${GH_WORKFLOW}/dispatches`,
            {
              method: 'POST',
              headers: {
                Authorization: `Bearer ${token}`,
                Accept: 'application/vnd.github+json',
                'X-GitHub-Api-Version': '2022-11-28',
              },
              body: JSON.stringify({ ref: GH_REF, inputs }),
            },
          );
          // GitHub answers a successful dispatch with 204 and no body.
          if (!res.ok && res.status !== 204) {
            throw new Error(`dispatch failed: ${res.status} ${await res.text()}`);
          }
          ctx.log.info(`Dispatched ${GH_WORKFLOW} in ${runnerRepo} for agent ${p.agentId}`);
        }),
      ],
    }),
  ],
});

That’s the whole KiCI side. The step body speaks plain HTTP to GitHub’s REST API, with no SDK to install. Everything specific to “how a machine comes up” lives there; swap it and you’ve pointed KiCI at a different pool.

The run it dispatches is a normal GitHub Actions workflow. It installs the agent and runs it once:

on:
  workflow_dispatch:
    inputs:
      claim_code:
        description: Single-use claim code the agent exchanges for its token
        required: true
      orchestrator_url:
        description: Orchestrator WebSocket URL the agent connects back to
        required: true
      agent_id:
        description: Agent id the orchestrator correlates the spawn with
        required: true
      labels:
        description: Comma-separated label set the pending job needs
        required: false
        default: ''
      agent_bundle_release:
        description: >-
          Release tag in this repo holding a `kici-admin agent package` tarball.
          Leave empty to install the published agent from npm.
        required: false
        default: ''

jobs:
  agent:
    runs-on: ubuntu-latest
    # A one-shot agent registers, runs one job and exits, so a run that is still
    # alive after this long is wedged (an agent the orchestrator refused, a job
    # that never dispatched) and must release the runner rather than bill the
    # default six hours. Raise it if your jobs legitimately run longer.
    timeout-minutes: 15
    permissions:
      contents: read
    steps:
      # Default path: the published agent, from npm.
      - name: Set up Node.js
        if: ${{ inputs.agent_bundle_release == '' }}
        uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: '24'
      - name: Install the KiCI agent
        if: ${{ inputs.agent_bundle_release == '' }}
        run: npm install -g kici-admin

      # Pinned path: a self-contained bundle your orchestrator produced with
      # `kici-admin agent package`. It vendors its own Node, so it needs no
      # setup-node. Use it to pin an exact agent build, or for runners that
      # cannot reach npm. Prepending it to PATH keeps the run step below uniform.
      - name: Install the KiCI agent (packaged bundle)
        if: ${{ inputs.agent_bundle_release != '' }}
        env:
          GH_TOKEN: ${{ github.token }}
          RELEASE: ${{ inputs.agent_bundle_release }}
        run: |
          gh release download "$RELEASE" --repo "$GITHUB_REPOSITORY" \
            --pattern 'kici-agent-linux-x64.tar.gz*' --clobber
          sha256sum -c kici-agent-linux-x64.tar.gz.sha256
          mkdir -p kici-agent-dist
          tar xzf kici-agent-linux-x64.tar.gz -C kici-agent-dist
          echo "$PWD/kici-agent-dist" >> "$GITHUB_PATH"

      - name: Run the one-shot KiCI agent
        env:
          KICI_ORCHESTRATOR_URL: ${{ inputs.orchestrator_url }}
          KICI_SCALER_CLAIM_CODE: ${{ inputs.claim_code }}
          KICI_AGENT_ID: ${{ inputs.agent_id }}
          KICI_LABELS: ${{ inputs.labels }}
          # Managed mode + zero idle timeout: register, run one job, exit.
          KICI_SCALER_MANAGED: '1'
          KICI_EXECUTION_MODE: bare-metal
          KICI_SCALER_IDLE_TIMEOUT: '0'
        run: exec kici-agent

The agent runs directly on the runner, installed with npm rather than wrapped in a container, so job steps that need Docker use the runner’s own daemon. If your runners can’t reach npm, or you want to pin an exact build, set agent_bundle_release to a release holding a kici-admin agent package tarball and it installs that instead. The bundle vendors its own Node, so that path skips the Node setup. KICI_SCALER_MANAGED and a zero KICI_SCALER_IDLE_TIMEOUT are the whole contract: register, run one job, exit.

And the scaler config that ties it together: an event scaler, no new backend type:

version: 1
scalers:
  - name: github-actions
    type: event
    maxAgents: 20
    # Repo identifiers, NOT workflow names: the reserved events are delivered
    # with `target.repos = provisioningTargets`, and the router filters
    # registrations by repo. A workflow name here matches no registration and
    # the scale-up reaches no subscriber at all.
    provisioningTargets:
      - myorg/infra
    labelSets:
      - labels: [github-actions]

Where each file lives

The pieces live in different places, and the GitHub repos do different jobs:

  • The workflow repo (myorg/infra) holds the provisioning and teardown workflows in .kici/workflows/. They’re ordinary workflows, not global ones. The scaler’s provisioningTargets names this repo, and KiCI delivers the scale-up and scale-down events only to workflows registered there. KiCI has to receive its pushes, like any repo it runs.
  • The runner repo (myorg/ci-runners) holds kici-agent.yml in .github/workflows/. Its Actions minutes are the ones you spend. KiCI never needs its pushes: the provisioning workflow dispatches runs in it through GitHub’s API.
  • scalers.yaml sits with your orchestrator, not in either repo.

Your application repos don’t change, apart from the label: a job that may wait in GitHub’s queue says runsOn: ['github-actions'].

The credential you can safely put in a dispatch input

There’s a real hazard hiding in “dispatch a workflow with inputs.” GitHub treats workflow_dispatch inputs as ordinary event data: the run reads them from github.event.inputs, and GitHub’s security guide says to mask any sensitive value that isn’t a GitHub secret. Put an agent token in an input, and one stray echo writes a live credential into the run’s log.

So KiCI never passes the token. The scale-up event carries a single-use claim code, and the agent redeems it itself, over the same WebSocket it has to open anyway, for a short-lived token that never leaves that connection. The claim code is safe to log: it’s one-time, short-lived, and useless once redeemed. Pass it through GitHub’s inputs, a cloud-init file, anywhere. The thing worth stealing never travels.

Teardown you get for free

The scary part of “boot compute on demand” is the instance nobody cleaned up. Here you mostly don’t have that problem: a GitHub Actions run ends when its one-shot agent exits, and GitHub caps every job on a hosted runner at 6 hours as a hard backstop. The run can’t outlive its job.

There’s still a teardown workflow, but it does less than the cloud version’s reaper. It only cancels a run GitHub has not yet marked finished, and only when the agent will never do useful work, because it never started or it went silent:

import {
  workflow,
  job,
  step,
  kiciEvent,
  SCALER_EVENT_NAMES,
  ScaleDownReason,
  ScalerScaleDownPayload,
} from '@kici-dev/sdk';

/** Must match the `name:` of the `event` scaler in your `scalers.yaml`. */
const SCALER_NAME = 'github-actions';
const GH_WORKFLOW = 'kici-agent.yml';

/**
 * The only two reasons that mean the run will never do useful work:
 *
 *   spawn-timeout      no agent ever registered against the spawn
 *   heartbeat-timeout  the agent registered, then went silent
 *
 * Every other reason is left alone. `shutdown` in particular is what a HEALTHY
 * one-shot agent emits when it exits after finishing its job — cancelling on it
 * would kill a run that is already succeeding. An unrecognised reason falls
 * through to doing nothing, which is always the safe default here: the run
 * reaps itself.
 */
export const CANCELABLE: ScaleDownReason[] = ['spawn-timeout', 'heartbeat-timeout'];

export default workflow('github-actions-autoscale-teardown', {
  on: [kiciEvent({ name: SCALER_EVENT_NAMES.scaleDown, match: { '$.scalerName': SCALER_NAME } })],
  jobs: [
    job('teardown', {
      runsOn: ['default'],
      // Binds the `github-actions` context. Required for BOTH halves: the
      // dispatch token this job reads, and `GITHUB_RUNNER_REPO` below — context
      // variables resolve only from the contexts a job binds, so an unbound
      // teardown reads `undefined` for the repo.
      context: 'github-actions',
      steps: [
        step('cancel-stranded-run', async (ctx) => {
          const p = ScalerScaleDownPayload.parse(ctx.rawPayload);
          if (!CANCELABLE.includes(p.reason)) {
            ctx.log.info(`teardown reason=${p.reason} agent=${p.agentId} action=skip`);
            return;
          }

          const runnerRepo = ctx.env.GITHUB_RUNNER_REPO ?? 'myorg/ci-runners';
          const token = await ctx.secrets.get('GITHUB_DISPATCH_TOKEN');
          const headers = {
            Authorization: `Bearer ${token}`,
            Accept: 'application/vnd.github+json',
            'X-GitHub-Api-Version': '2022-11-28',
          };

          // `kici-agent.yml` sets its `run-name` to `kici-agent {agent_id}`,
          // which is how a scale-down finds the run its agent belongs to. One
          // page bounds it for most pools: the run is at most one spawn-timeout
          // window old, so a pool creating fewer than 100 runs of this workflow
          // inside that window always finds it here. A busier pool needs
          // pagination. What decides is the age of the RUN — the list is newest
          // first — not how fast the teardown follows the scale-down.
          const listed = await fetch(
            `https://api.github.com/repos/${runnerRepo}/actions/workflows/${GH_WORKFLOW}/runs?per_page=100`,
            { headers },
          );
          if (!listed.ok) {
            throw new Error(`listing runs failed: ${listed.status} ${await listed.text()}`);
          }
          const body = (await listed.json()) as {
            workflow_runs: Array<{ id: number; name?: string; status: string }>;
          };
          const run = body.workflow_runs.find((r) => r.name === `kici-agent ${p.agentId}`);

          // Anything GitHub has not marked `completed` is still live — that
          // covers `queued` and `in_progress` plus the pre-start states
          // (`requested`, `waiting`, `pending`), which is exactly where a
          // spawn-timeout run sits. Listing the live states instead would
          // decline to cancel the case this workflow exists for.
          if (!run || run.status === 'completed') {
            ctx.log.info(
              `teardown reason=${p.reason} agent=${p.agentId} action=skip (no live run)`,
            );
            return;
          }

          const cancelled = await fetch(
            `https://api.github.com/repos/${runnerRepo}/actions/runs/${run.id}/cancel`,
            { method: 'POST', headers },
          );
          // 409 is GitHub refusing the cancel, most often because the run
          // finished between the list above and this call. A teardown the
          // orchestrator could not deliver is retried, so treating that as a
          // failure would fail the workflow forever over a run that is already
          // in the state the teardown wanted.
          if (cancelled.status === 409) {
            ctx.log.info(
              `teardown reason=${p.reason} agent=${p.agentId} action=skip ` +
                `(run ${run.id} not cancelable: 409, most often already completed)`,
            );
            return;
          }
          if (!cancelled.ok) {
            throw new Error(`cancel failed: ${cancelled.status} ${await cancelled.text()}`);
          }
          ctx.log.info(
            `teardown reason=${p.reason} agent=${p.agentId} action=cancel run=${run.id}`,
          );
        }),
      ],
    }),
  ],
});

For a job that already finished, there’s nothing to cancel: the run is gone. Compare that to a cloud VM, where teardown is a delete call you’d better not miss.

The trade-off: you inherit GitHub’s queue and limits

Compute you already pay for is a real win, and it comes with a real cost you should see clearly. When you borrow someone else’s pool, you inherit that pool’s guarantees. For GitHub Actions that means:

  • Queue latency becomes your scheduling latency. A dispatched run waits for a hosted runner before the job even starts, and GitHub discards a queued run that no hosted runner picks up within 45 minutes. If you need an agent up in under a second, this is the wrong pool.
  • The minutes are GitHub’s, and so are the prices. Past your plan’s included minutes, a private repo pays GitHub’s per-minute rate: $0.006 a minute for a standard Linux runner, as of September 2026.
  • Your fan-out ceiling is GitHub’s concurrency limit, not KiCI’s reservation math. Standard hosted runners run up to 20 concurrent jobs on Free, 60 on Team and 500 on Enterprise.
  • You get best-effort placement. The example asks for ubuntu-latest, with no say over instance class or region, and a GitHub incident is a KiCI capacity incident.

KiCI doesn’t paper over this. When a provision fails (GitHub down, dispatch rejected, minutes exhausted) the scaler reports the failure and backs off a pool that keeps failing, instead of hammering it. You see the problem; you don’t get a silent stall.

So this is a choice, and it’s per scaler. Latency-critical jobs point at a cloud scaler or a warm pool you keep hot. Bursty, tolerant work (nightly matrices, PR fan-out, background jobs that need to finish sometime) points at the GitHub pool. Same orchestrator, different label. You decide which jobs are allowed to wait in a queue.

The same pattern on GitLab CI, Nomad or a spare box

None of this is specific to GitHub. Look at what each piece needs:

  • What KiCI brings is fixed and engine-independent: noticing demand, minting the claim code, handing the pending job to whatever registers, spawn and idle timeouts, and the backoff when a pool keeps failing.
  • What you write is the one part that changes: the step body that asks your engine to start a process with a few env vars. For GitHub it’s a workflow_dispatch. For GitLab CI it’s a pipeline trigger. For Nomad it’s a batch job. For a spare box in the corner it’s a queued docker run.
  • What the agent needs is small and the same everywhere: a Node runtime, a way out to the orchestrator, and a handful of env vars.

Any system that can run a process on request is a burst pool for KiCI. GitHub Actions is the one almost everyone already pays for. And the trade-off carries over too: each engine you borrow brings its own queue and limits.

Set it up

Let your coding agent do it. Every step below is a file to write or a command to run, which is the kind of work a coding agent is good at. Give yours this post’s URL and ask it to set up a GitHub Actions burst pool by following these steps. For the SDK details, point it at llms.txt or pipe kici docs llm sdk into its context.

The steps use myorg/infra for the workflow repo and myorg/ci-runners for the runner repo. Every file shown above is in examples/github-actions-autoscale/, ready to copy.

  1. Sign up and bring up an orchestrator. Create a free account at app.kici.dev and follow the 5-minute quickstart through Part 2, so pushes from your GitHub App reach the orchestrator.

  2. Create the workflow repo. Create a repo such as myorg/infra and install the quickstart’s GitHub App on it, so KiCI receives its pushes. Clone it and run kici init in the clone. That creates .kici/, with the package.json that declares @kici-dev/sdk. Delete any starter workflow it adds that you don’t want to run.

  3. Make the orchestrator reachable from GitHub’s runners. A hosted runner dials the orchestrator’s agent endpoint (/ws) from GitHub’s network, so expose that endpoint at an address the runners can reach, such as wss://ci.example.com/ws. If it differs from the orchestrator’s own KICI_ORCHESTRATOR_URL, set it as orchestratorUrl on the scaler entry in step 7.

  4. Pick the agent that runs the provisioning workflow. The provisioning workflow runs on an agent too, and it can’t wait for the pool it’s growing. The quickstart’s scaler already spawns one: in both KiCI workflows, you’ll change runsOn: ['default'] to that scaler’s labels, ['linux', 'container'] for the Docker quickstart or ['linux', 'bare-metal'] for bare metal. Those agents keep their default roles, so they also pack the workflow’s source before the job runs.

  5. Add the runner workflow to the runner repo. Commit kici-agent.yml to .github/workflows/kici-agent.yml on the runner repo’s default branch. GitHub dispatches only a workflow file that’s on the default branch. The provisioning workflow dispatches ref main, so change GH_REF in it if your default branch has another name.

  6. Store the token in a github-actions context. Create a fine-grained GitHub token with Actions: read and write on the runner repo. Then, where you run kici-admin against your orchestrator, create the context, store the token in its scope, and point it at the runner repo:

    kici-admin context create --org <orgId> --name github-actions
    kici-admin secret set <orgId> github-actions GITHUB_DISPATCH_TOKEN --prompt
    kici-admin context bind --org <orgId> --env github-actions --scope github-actions
    kici-admin variable set <orgId> github-actions GITHUB_RUNNER_REPO --value myorg/ci-runners

    Leave the context’s protection rules off. An approval hold on it would hold every scale-up, and the agents would never appear.

  7. Declare the event scaler. Open the scalers.yaml the quickstart created: next to docker-compose.yaml for Docker, or ~/.config/kici/scalers.yaml for bare metal. Add the github-actions entry shown above under scalers:, next to the quickstart’s own. provisioningTargets is the workflow repo from step 2, written as owner/repo. Load it with kici-admin scaler reload. It checks the file and applies all of it or none of it, on the orchestrator it points at and every orchestrator connected to it, and prints what changed or why it refused the file. Here it prints added: github-actions.

  8. Add the two KiCI workflows to the workflow repo. In the clone from step 2, save provision.workflow.ts as .kici/workflows/github-actions-provision.ts and teardown.workflow.ts as .kici/workflows/github-actions-teardown.ts. Make the runsOn change from step 4 in both. The workflow repo then looks like this:

    myorg/infra/
    └── .kici/
        ├── package.json
        ├── workflows/
        │   ├── github-actions-provision.ts
        │   └── github-actions-teardown.ts
        └── kici.lock.json          # written in the next step
  9. Compile and push to the default branch. From the root of the clone:

    kici compile
    git add .kici/
    git commit -m "ci: add GitHub Actions burst pool workflows"
    git push origin main

    KiCI registers the scale-up and scale-down subscriptions only from the default branch’s lock file, so push to that branch (main here). Until that push, a scale-up reaches no one. Confirm both workflows registered with kici-admin registration list --repo myorg/infra: it lists github-actions-autoscale-provision and github-actions-autoscale-teardown.

  10. Send jobs to the pool and watch them. In any repo KiCI runs, give a job you’re happy to let wait in GitHub’s queue runsOn: ['github-actions'], and push it. That repo needs no other change. A kici-agent <agent-id> run appears in the runner repo’s Actions tab. It registers, runs the job, and finishes. If no run appears, the provisioning workflow’s own run shows the dispatch error, and kici-admin diagnose names the failure on the scaler:github-actions row.

The machine that runs a KiCI job can be anything that runs a process, and the TypeScript that brings it up is yours.

Burst your CI onto GitHub Actions minutes

Free plan. Full features on the hosted dashboard at no cost. You run the orchestrator and agents on your own machines, and the dashboard never receives your source or secrets. KiCI is in public beta. Pin versions for production.

About the author

· Founder of KiCI

Alberto builds KiCI and runs it for KiCI's own production deployments, after a career building and operating the deployment systems other teams depend on.