Documentation

conveyor.ai

A single, long-running factory that drives software work across many remote projects. ("conveyor.ai" is the product name; the code, the CLI helpers and the data directories still say factory — that's the internal term for the long-running server.) You describe each project's pipeline — a mapping from an issue status to a tool — and the factory runs those tools on the project's own container over SSH, watches them, and shows everything in a dashboard.

It is deployed once, as one container, and manages N projects. (Earlier versions ran one Paperclip-based factory per project; that design is preserved at the git tag paperclip-final.)

How it works

                 ┌─────────────────────── factory container ───────────────────────┐
   you  ───────▶ │  Dashboard + REST/SSE  ·  SQLite  ·  heartbeat engine            │
  (browser)      │                                                                  │
                 │   SSH (launch tools + setup)          HTTP (tools call back)     │
                 └───────────┬───────────────────────────────────▲──────────────────┘
                             │                                    │
                 ┌───────────▼───────────┐            ┌───────────┴───────────┐
                 │   project A container  │            │   project B container  │
                 │   tool runs here       │  ......    │   tool runs here       │
                 └────────────────────────┘            └────────────────────────┘
  • SSH is the control channel: the factory SSHes into a project to launch a tool (detached) and to run a tool's one-time setup.
  • HTTP is the data channel: once running, a tool calls back to the factory — streaming output, progress, heartbeats, status changes, and input requests. Only outbound (project → factory) reachability is needed; put both on a shared Docker network (or a tunnel) and they reach each other by DNS.
  • A heartbeat starts tools when an issue enters a triggering status (up to a per-step concurrency cap) and reaps runs whose process has died.

The full picture — how the core factory runs, how a project is provisioned and connected, what lands in the container vs. the repo, and what the factory commits vs. what it never does: docs/architecture.md.

Core concepts

  • Tool — a reusable unit of work: a setup script (installs it on a project) + an exec command (runs it) + param definitions. Factory-scoped (shared) or project-scoped (shadows a factory tool of the same name).
  • Workflow — a named bundle of statuses + a pipeline. Factory workflows are shared/reusable; each project also has a private custom workflow. A project uses one workflow (custom by default, or a factory one).
  • Project — a remote container: SSH info, a root_dir to work in, a data_folder for factory state, arbitrary params, an issue-key prefix (defaults to the first 3 letters of the name), and the workflow it uses.
  • Pipeline step — on: <status> → run tool (with params), at most max at once.
  • Issue — a unit of work with a human key (AIF-12, from its project's prefix + a per-project counter), a title, body, status, documents, and a derived execution state (running / needs_input / pending / offline / none).

Quick start

1. Deploy the factory

cp .env.example .env
#   set FACTORY_NAME, DEPLOY_SSH, GOOGLE_CLIENT_ID/SECRET, FACTORY_PUBLIC_URL,
#   SERVICE_TOKEN, (FACTORY_NETWORK/HOSTNAME)
./scripts/factory.sh up

This builds the image on DEPLOY_SSH (docker-over-SSH) and starts it. Open the dashboard at the URL printed by up (http://HOST:3100) and sign in with Google. On a brand-new factory the first person to sign in becomes the founding user automatically; after that, add teammates under Settings → General → Users (there is no self-service signup).

2. Let the factory reach your projects

The factory generates its own SSH key on first boot. Authorize it on each project container:

./scripts/factory.sh pubkey     # prints the factory's public key
#   append that line to  <project>:~/.ssh/authorized_keys

Projects must be reachable from the factory over SSH, and must be able to reach the factory over HTTP at FACTORY_SELF_URL (default http://<FACTORY_HOSTNAME>:3100) — easiest on a shared FACTORY_NETWORK.

3. Describe your tools & projects (declarative config)

Config lives on the container at /data/factory/config/:

config/
  tools/*.yaml        # factory-scoped tools           (see config/tools/claude-agent.yaml)
  workflows/*.yaml    # shared factory workflows        (see config/workflows/default.yaml)
  projects/*.yaml     # one project per file — references a workflow by name,
                      #   or defines statuses/pipeline inline (its custom workflow)

Edit the tracked ./config tree, then push + reload:

./scripts/factory.sh config-push ./config
./scripts/factory.sh reload

The repo ships a claude-agent example tool and an example project template.

4. Connect a project and submit work

In the dashboard: select the project → Connect (installs helpers + runs tool setup over SSH) → + New issue. When the issue's status matches a pipeline step, the heartbeat launches the tool; watch its output stream live, answer any questions it asks, and follow it through the pipeline.

Deploying a project container

The factory operates on projects that live in their own containers. Stand one up from a GitHub repo with one command — plain Docker, synthesized from a default image + the repo's own docker files + overrides:

./scripts/factory.sh project up <owner/repo>          # simplest: just the repo
./scripts/factory.sh project up <repo> PROJECT_HOSTNAME=api BASE_IMAGE=node:20-bookworm

How it works (this runs on your machine; the container runs on the factory's host on FACTORY_NETWORK):

  1. shallow-clones the repo (using GH_TOKEN) to read its .factory/development/ files and serve as build context;
  2. synthesizes the build: the factory's default project/Dockerfile with the repo's .factory/development/Dockerfile fragment appended; the default project/features.json merged with the repo's .factory/development/features.json; and a generated compose with the repo's .factory/development/compose.override.yaml layered on;
  3. builds the image — with devcontainer build when any features are declared (so you can reuse the published devcontainer features ecosystem), else a plain docker build — then runs it on FACTORY_NETWORK with a DNS alias + persistent volumes;
  4. the entrypoint clones the repo from its URL into a ~/workspaces volume at init (not a bind mount), authorizes the factory + your SSH keys, and runs the repo's .factory/development/setup.sh (or a default lockfile install).

Default image ships Node, Claude Code, TypeScript, curl; gh comes from the default feature. The container runs sshd as user dev. Devcontainer is used only as a feature-aware image builder — no devcontainer runtime, no workspace mounts; the built image's feature containerEnv is folded into the container so SSH sessions see it.

  • Prerequisites: FACTORY_NETWORK set (so the factory reaches the container by DNS); GH_TOKEN in .env for private clones (both the local read-clone and the in-container clone use it). Node/npx is needed where the BUILD runs — the factory itself, which is a Node image, so there is nothing to install; it is no longer a prerequisite on your machine (AIF-215).
  • Config precedence (low → high): factory defaults → the repo's .factory/development/env → KEY=VAL args. Recognized: BASE_IMAGE, PROJECT_NAME, PROJECT_HOSTNAME, PROJECT_REPO_DIR; any other var is passed into the container for .factory/development/setup.sh.
  • Persistence: per-container named volumes keep the checkout (/home/dev/workspaces/<name>) and the one-time Claude login across rebuilds.
  • Managing it:

    ./scripts/factory.sh project ssh <name> [cmd]   # shell in as dev
    ./scripts/factory.sh project redeploy <name>    # rebuild + restart (reuses config)
    ./scripts/factory.sh project down <name>        # stop (keep volumes)
    ./scripts/factory.sh project rm <name>          # remove + delete volumes/image
    ./scripts/factory.sh project ps | logs <name> | list

project up prints the exact values to register it in the dashboard (name, SSH host = the DNS alias, user dev, port 22, root dir).

What a project repo can define for its factory development container (all optional) — copy the samples in project/examples/. These live under .factory/development/, clearly a factory interface; your repo's own deploy/ (live deployment) is separate and never read by the factory.

file (in the project repo)effect
.factory/development/envvariable overrides; also passed to setup.sh as env
.factory/development/features.jsondevcontainer features (strict JSON), merged over the factory default
.factory/development/Dockerfileextra build steps appended to the default image (fragment: no FROM)
.factory/development/setup.shdev setup run after clone (else a default lockfile install)
.factory/development/compose.override.yamlcompose override — ports, mounts, sidecars (service dev)

Authoring a tool

A tool's exec runs on the project in root_dir with a rich environment:

ProvidedWhat
$FACTORY_ISSUE_KEYthe issue's human key, e.g. AIF-12 (may be empty)
$FACTORY_ISSUE_DIRissue dir on the project (contains ISSUE.md)
$FACTORY_DOCS_DIRwrite documents here (surfaced in the dashboard)
$FACTORY_RUN_DIRthis run's scratch/output dir
PROJECT_*every project param (e.g. PROJECT_REPO)
PARAM_*step params + tool defaults (e.g. PARAM_PHASE)

And these helper commands on PATH:

  • factory-progress "msg" [pct] — report progress.
  • factory-ask "prompt" — ask the user; blocks until they answer in the dashboard; prints the answer to stdout.
  • factory-set-status <status> — move the issue on (this is how a step hands off to the next step in the pipeline).
  • factory-comment "text" — add a comment to the issue's thread (the factory prefixes it with the tool's name); also reads the body from stdin.
  • factory-summary "text" — set the issue's summary: a few sentences on what has been done and where it stands, shown at the top of the issue's Info tab so a human can catch up without reading the output and comment history. Unlike a comment it overwrites, so each tool restates the whole current picture; also reads from stdin.
  • factory-ready ["what to try"] — report the implementation testable. The dashboard's Run button for an issue stays disabled until some tool says this, so a human never stages a branch with nothing on it yet.

Tool comms are best-effort with retry: a tool keeps running even if the factory is briefly unreachable, and reconnects after a factory restart.

Project containers need bash, curl, coreutils, ps, and setsid (util-linux) for the wrapper; a tool's setup installs whatever else it needs.

Operating

./scripts/factory.sh up|down|ps|logs        # lifecycle
./scripts/factory.sh redeploy               # safe rebuild: build → swap → health-check → auto-rollback
./scripts/factory.sh runner up|down|logs|ps # manage the containerized CI deploy runner (see docs/self-deploy.md)
./scripts/factory.sh ssh [cmd]              # shell into the factory box
./scripts/factory.sh url                    # dashboard URL (+token)
./scripts/factory.sh pubkey                 # factory SSH public key
./scripts/factory.sh config-push [dir]      # push local config, then reload
./scripts/factory.sh reload                 # reconcile config in place

State (SQLite DB, SSH keypair, run logs, config) lives on the factory-data volume and survives rebuilds. Run several factories on one host with unique FACTORY_NAME + ports per clone.

Self-deploy (the factory manages its own repo)

The factory can manage this very repo as a project — the agent makes changes on branches and merges to main; a self-hosted GitHub Actions runner (itself a container on the docker host) redeploys on push (build → swap → health-check → rollback), so no deploy credentials ever live in an agent-operated container. The runner mounts the host docker socket (docker-out-of-docker) to swap the factory as a sibling container, and joins FACTORY_NETWORK to health-check it by DNS:

RUNNER_PAT=<repo-scoped GitHub PAT> ./scripts/factory.sh runner up
./scripts/factory.sh runner logs|ps|down|rm

One-time runner + secret setup (incl. the Docker Desktop socket note for a Mac mini host): docs/self-deploy.md.

Ad hoc conversations

Every issue and project page has a Conversation tab — a chat box wired to a Claude agent on the factory container. It gets that issue's (or project's) context and can call the operator API, answer a stage that is waiting on input, or SSH in and do real work in the issue's branch worktree. The projects screen has one too (/conversation), attached to the whole factory: it knows every project and works across all of them. Needs a one-time claude sign-in on the factory (or ANTHROPIC_API_KEY): docs/conversation.md.

API and MCP access

/api/v1 is a versioned REST surface over projects, issues, epics, comments, dependencies and run logs, and /mcp is the same capabilities as MCP tools, so a coding agent can read and drive the factory directly. Both take a personal API token (Settings → API, which also prints ready-made Claude Desktop and Claude Code snippets) or an OAuth access token. Full reference — credentials, every route, the error codes, the tool list and the connection recipes: docs/api.md.

Linear sync (optional)

Set LINEAR_API_KEY in .env and add a linear: { team: … } block to a project's config, and that project's issues, statuses and comments sync both ways with a Linear team. Unset key = the feature is completely inert. Note that state reconciliation rewrites the Linear team's workflow states to match the factory's: docs/linear-sync.md.

Messaging questions and replies (optional)

Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN and TWILIO_FROM_NUMBER in .env, then pick SMS with your phone number in Settings → Notifications. A stage that needs input messages you the question, and replying answers it — the reply reaches the waiting tool exactly as if you had typed it in the dashboard. Point the Twilio number's inbound webhook at <FACTORY_PUBLIC_URL>/twilio/sms. Only one question is sent at a time, so a plain reply is never ambiguous; the rest wait in the dashboard as usual. Unset credentials = the channel is inert.

WhatsApp is the same flow over a different service: add TWILIO_WHATSAPP_FROM and TWILIO_WHATSAPP_TEMPLATE_SID and pick it in the same place. The template SID is required because WhatsApp only accepts pre-approved templates outside a session you opened, and a stage's question arrives cold — approve one with two variables ({{1}} the issue, {{2}} the question). One service is active at a time and both share the number you save, so switching is one click and no retyping. Point the WhatsApp sender's inbound webhook at the same /twilio/sms URL.

Repo layout

Dockerfile                 factory image (Node 22 + the server)
compose.yaml               single `factory` service (+ compose.external-net.yaml overlay)
scripts/
  factory.sh               deploy/manage over docker-over-SSH
  container-init.sh         entrypoint: sshd + exec the server
  ssh.sh                   `factory.sh ssh` helper
factory/server/            the factory server (TypeScript)
  src/                     config, db, ssh, setup, runner, engine, api, routes
  remote/                  helper scripts installed on each project
  web/                     the dashboard (static, no build)
config/                    example tools + project template (push to the container)

Security notes

  • Every page and every /api/* call requires a signed-in user (Google OAuth) — no credential is ever accepted from a URL query string. The factory can trigger remote code on your projects, so still keep the port behind a trusted network/tunnel.
  • SERVICE_TOKEN is a header-only credential for factory-owned automation (the self-deploy runner). Treat it like a root password.
  • Each project gets a durable bearer token authenticating its containers' callbacks; param values are passed as env (never interpolated into a shell).
  • Project SSH details live in the SQLite DB on the volume (plaintext) — treat the host accordingly.