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
setupscript (installs it on a project) + anexeccommand (runs it) + param definitions. Factory-scoped (shared) or project-scoped (shadows a factory tool of the same name). - Workflow — a named bundle of
statuses+ apipeline. 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_dirto work in, adata_folderfor factory state, arbitraryparams, an issue-keyprefix(defaults to the first 3 letters of the name), and the workflow it uses. - Pipeline step —
on: <status>→ runtool(withparams), at mostmaxat 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 upThis 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_keysProjects 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 reloadThe 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-bookwormHow it works (this runs on your machine; the container runs on the factory's
host on FACTORY_NETWORK):
- shallow-clones the repo (using
GH_TOKEN) to read its.factory/development/files and serve as build context; - synthesizes the build: the factory's default
project/Dockerfilewith the repo's.factory/development/Dockerfilefragment appended; the defaultproject/features.jsonmerged with the repo's.factory/development/features.json; and a generated compose with the repo's.factory/development/compose.override.yamllayered on; - builds the image — with
devcontainer buildwhen any features are declared (so you can reuse the published devcontainer features ecosystem), else a plaindocker build— then runs it onFACTORY_NETWORKwith a DNS alias + persistent volumes; - the entrypoint clones the repo from its URL into a
~/workspacesvolume 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_NETWORKset (so the factory reaches the container by DNS);GH_TOKENin.envfor 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=VALargs. 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/env | variable overrides; also passed to setup.sh as env |
.factory/development/features.json | devcontainer features (strict JSON), merged over the factory default |
.factory/development/Dockerfile | extra build steps appended to the default image (fragment: no FROM) |
.factory/development/setup.sh | dev setup run after clone (else a default lockfile install) |
.factory/development/compose.override.yaml | compose override — ports, mounts, sidecars (service dev) |
Authoring a tool
A tool's exec runs on the project in root_dir with a rich environment:
| Provided | What |
|---|---|
$FACTORY_ISSUE_KEY | the issue's human key, e.g. AIF-12 (may be empty) |
$FACTORY_ISSUE_DIR | issue dir on the project (contains ISSUE.md) |
$FACTORY_DOCS_DIR | write documents here (surfaced in the dashboard) |
$FACTORY_RUN_DIR | this 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, andsetsid(util-linux) for the wrapper; a tool'ssetupinstalls 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 placeState (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|rmOne-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_TOKENis 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.