fctl is open source, so you can host all of it yourself. app.fctl.sh is a managed control plane; this page sets up your own, on your machines or on Cloudflare.
An fctl instance is a control plane (the API and the web app, with the
Durable Objects and the D1 database that hold its state) and the runners
that connect to it: your machines, each with the fctl host service on it.
Nothing in the code is tied to fctl.sh: everything instance-specific is
configuration. The control plane runs in one of two ways, with the same
features, settings, runners and apps:
| On your own machine or cluster | On Cloudflare | |
|---|---|---|
| What runs | one container, ghcr.io/hyperstructure/fctl-control: the Worker on workerd, Cloudflare's open-source Workers runtime |
one Worker with Durable Objects and a D1 database, on your account |
| What you need | a machine with Docker, or a Kubernetes cluster; no account anywhere | a Cloudflare account with a domain on it |
| HTTPS | a reverse proxy of your choice, or plain http on a network you trust | Cloudflare's, on your domain |
| Backups | copies of its data directory | D1's 30 days of point-in-time history |
| Made for | trying fctl out, a spare machine, a homelab, a cluster you run | a team's everyday instance, as app.fctl.sh |
Set up the control plane one of the two ways. The rest of this page is the same for both, in the order to follow:
- The first account and your team.
- GitHub: a GitHub App that agents clone, push and open pull requests as, and whose webhook keeps sessions' pull requests current. Without it, projects have no repositories.
- Optionally, a review app: a second GitHub App that review agents post as, so that their reviews stand apart from what the agents that opened the pull requests answer.
- Optionally, Linear: an OAuth app with which teams delegate Linear issues to fctl.
- Optionally, Google sign-in and email.
- Runners, the machines agents run on.
Each app takes a few minutes in GitHub's or Linear's settings, then its
IDs and secrets go to the control plane: as environment variables on your
own machine, in the instance file and with cargo xtask secrets on
Cloudflare.
The image runs the Worker and the web app that run on Cloudflare, on
workerd through Miniflare, with all its state (the database, every
session's event log, attached files) in /data
(ADR 0026).
It listens on port 8787, runs on amd64 and arm64 machines alike, and each
release of fctl publishes it.
docker run -d --name fctl --restart unless-stopped \
-p 8787:8787 -v fctl-data:/data \
-e FCTL_PUBLIC_URL=http://192.168.1.20:8787 \
ghcr.io/hyperstructure/fctl-control
docker logs fctlFCTL_PUBLIC_URL is the address people open the app at and hosts connect
to: the machine's address on your network here, http://localhost:8787
to keep it to this machine, or the https address of a
reverse proxy in front. The log has the link
that creates the first account, the primary instance owner's, after what
plain http gives up:
Made a master key in /data/master-key. It encrypts stored credentials and secrets: keep it with the data (or set FCTL_MASTER_KEY to it).
Applied 18 database migrations
fctl control plane 0.11.0 at http://192.168.1.20:8787, listening on 0.0.0.0:8787, data in /data
Over plain http, anyone on the network in between can read sign-ins and tokens, and browsers offer passkeys, notifications and installing the app only over https (or on localhost): keep it to a network you trust, or put a reverse proxy with TLS in front.
Create the primary instance owner's account (the link works once):
http://192.168.1.20:8787/setup#token=…
Open it, then carry on with your team.
fctl-data is a volume Docker makes; a directory of yours works too
(-v /srv/fctl:/data) once user 1000, which runs the server in the image,
can write to it.
deploy/standalone/compose.yaml does
the same with Docker Compose. To build the image yourself, in a clone:
docker build -f deploy/standalone/Dockerfile -t fctl-control .
deploy/standalone/kubernetes.yaml
has a StatefulSet of one, with a volume for its data, a Service and an
Ingress. Set FCTL_PUBLIC_URL and the Ingress's host in it to your address,
then:
kubectl create namespace fctl
kubectl -n fctl create secret generic fctl \
--from-literal=FCTL_MASTER_KEY="$(openssl rand -base64 32)"
kubectl -n fctl apply -f kubernetes.yaml
kubectl -n fctl logs statefulset/fctl-controlKeep it to one replica. Its state is SQLite files on the volume, and two
servers on them would each run the same sessions: a second one waits for
the first to stop, and gives up after 45 seconds. The Ingress has to pass
WebSockets, as ingress-nginx, Traefik and the cloud providers' do, and
bodies of up to 25 MB, for attachments. The pod reads the fctl Secret as
environment variables: add the GitHub App's,
Google's and Resend's
secrets there when you use them.
With Rust and Node.js 22 or newer, as for development:
git clone https://github.com/hyperstructure/fctl && cd fctl
cargo xtask serve --public-url http://192.168.1.20:8787It builds the Worker and the web app, then runs the server the image runs,
with its state in .fctl-serve/ (--data for another directory). It
listens on every address unless the URL is on this machine.
The server takes the settings the Worker takes on Cloudflare, as
environment variables. Each can come from a file instead, <NAME>_FILE, as
Docker and Kubernetes secrets do.
| Variable | Default | What it is |
|---|---|---|
FCTL_PUBLIC_URL |
http://localhost:8787 |
The address people open the app at and hosts connect to |
FCTL_INSTANCE_NAME |
fctl |
Shown in the app and in emails |
FCTL_MASTER_KEY |
made in the data directory | 32 random bytes in base64 (openssl rand -base64 32) that encrypt stored credentials and secrets |
FCTL_SETUP_TOKEN |
made at each start | The token in the setup link, which works until the first account exists |
FCTL_DATA_DIR |
/data in the image |
Where the state lives |
FCTL_PORT, FCTL_LISTEN |
8787 (or PORT), 0.0.0.0 |
Where it listens |
FCTL_SEARCH_ENGINES |
0 |
1 lets search engines index the sign-in page (search engines) |
FCTL_TRUST_PROXY |
0 |
How many reverse proxies in front add X-Forwarded-For |
GITHUB_APP_ID, GITHUB_APP_SLUG, GITHUB_CLIENT_ID, GITHUB_DEFAULT_OWNER, GITHUB_APP_PRIVATE_KEY, GITHUB_CLIENT_SECRET, GITHUB_WEBHOOK_SECRET |
The GitHub App | |
GITHUB_REVIEW_APP_ID, GITHUB_REVIEW_APP_SLUG, GITHUB_REVIEW_APP_PRIVATE_KEY |
The app review agents post as | |
LINEAR_CLIENT_ID, LINEAR_CLIENT_SECRET, LINEAR_WEBHOOK_SECRET |
Linear | |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
Google sign-in | |
RESEND_API_KEY, FCTL_EMAIL_FROM |
Without FCTL_MASTER_KEY, the server makes a master key the first time it
starts and keeps it in master-key in the data directory, next to what it
encrypts, so a copy of the directory has both. To keep them apart, as the
Kubernetes example does, set it from a secret. Once the server has made
one, set that one: replacing the key makes every stored Claude credential
and project secret unreadable, so the server refuses another.
Plain http works on a network you trust. Over it, though, anyone on the
network in between reads sign-ins and tokens, and browsers offer passkeys,
notifications and installing the app only over https (or on localhost).
For those, put a reverse proxy with TLS in front, which passes WebSockets
and bodies of up to 25 MB, and set FCTL_PUBLIC_URL to its https address.
Caddy, for one, gets the certificate itself:
fctl.example.com {
reverse_proxy localhost:8787
}
On a Tailscale network,
tailscale serve --bg 8787 gives the machine an https address
(https://<machine>.<tailnet>.ts.net) without making it public.
Behind a proxy, set FCTL_TRUST_PROXY=1 (2 behind two of them, and so
on). The control plane limits sign-in attempts and records addresses in
the audit log by the address each request comes from, which a proxy puts
in the X-Forwarded-For header it adds: without the setting, every request
seems to come from the proxy. With it, the server takes the address the
outermost proxy saw, and ignores what a client wrote there itself. It
counts proxies rather than knowing them, so the server's port must be
reachable only through them: a client that connects to it directly would
choose its own address.
- Upgrades. Pull the new image (or change the tag) and start the
container again: the server applies the database migrations as it starts.
Running sessions carry on, and their hosts reconnect. Hosts and agents
tolerate a control plane one version ahead, and hosts update themselves
to its version within minutes, from fctl.sh unless their
host.tomlsets anotherdownload_base(Host updates); a version that isn't published there, such as a build of your own, leaves them where they are. - Backups. Everything is in the data directory. Stop the server and
copy it, or snapshot its volume:
docker run --rm -v fctl-data:/data -v "$PWD":/backup busybox tar czf /backup/fctl-data.tgz /data. Unless you setFCTL_MASTER_KEY, the copy holds the master key too: keep it as safe as the key. - Logs.
docker logs fctl, orkubectl -n fctl logs statefulset/fctl-control: what the control plane logs, and its errors.fctl host logs, on a runner, shows its host service's. - Housekeeping. The server runs the hourly job of Deletion at 17 minutes past each hour, while it runs.
- What it takes. About 500 MB of memory and little CPU at rest. The data grows with sessions' transcripts and attached files.
- Removing it.
fctl host uninstallon each runner first, while the control plane can still stop their sessions (--forcestops the ones that run), then remove the container and its volume.
An instance on Cloudflare is one Worker (the API, the web app and its Durable Objects) and a D1 database, on your Cloudflare account and domain.
- A Cloudflare account with a domain (zone) on it. The Workers Paid plan ($5 a month) is recommended for its higher limits; a small team stays well inside what it includes.
- Rust (via rustup; the repository pins its toolchain) and Node.js 22 or newer.
- Wrangler signed in to that account: run
npx wrangler loginin the repository, or exportCLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_ID. An API token needs Workers Scripts: Edit, D1: Edit, Workers KV Storage: Edit, Account Settings: Read, and, on the zone, Workers Routes: Edit and DNS: Edit (for the custom domain).
git clone https://github.com/hyperstructure/fctl && cd fctl
cp deploy/instances/example.toml deploy/instances/acme.tomlEdit deploy/instances/acme.toml:
worker = "fctl" # the Worker's name in your account
instance_name = "Acme factory" # shown in the app and in emails
public_url = "https://fctl.acme.dev" # its host becomes the Worker's custom domain
[d1]
database_name = "fctl"
database_id = "" # filled in by --create
location = "weur" # optional primary locationNothing in this file is secret; commit it to your fork if you like.
To let search engines index the sign-in page, add search_engines = true
(search engines).
Cloudflare serves the instance over https, and over plain http too. To
redirect every plain-http request to https, for a page, a file or the API,
add https_only = true. The Worker then runs for every request, the web
app's files included, and Cloudflare counts each as a Worker request.
cargo xtask deploy acme --createThis creates the D1 database (recording its id in the instance file), builds
the web app and the Worker (Rust compiled to WebAssembly), renders
crates/fctl-control/wrangler.deploy.toml, applies the database migrations,
and deploys to your domain. Run it again, without --create, for every
upgrade. When Cloudflare's API throttles a step (it answers 429 for a while
once a token or an address has made too many requests), the step runs again
once the wait the API asks for is over, up to four times. So does a step
that a server in front of the API failed, answering it with a 5xx that
isn't JSON (wrangler says "Received a malformed response from the API"),
5, 15 and 45 s later; creating the database doesn't, since it may have
been made before the answer failed.
cargo xtask secrets acmeThis sets, once:
FCTL_MASTER_KEY— 32 random bytes that encrypt stored credentials and sign state. Keep it: replacing it makes every stored Claude credential and project secret unreadable.FCTL_SETUP_TOKEN— a one-time token. The command prints the setup link,https://fctl.acme.dev/setup#token=…. Open it to create the account of the primary instance owner; after that, the link does nothing.--new-setup-tokenprints a fresh one.
Other secrets go in with --set, the GitHub App's key with
--github-key and the review app's with --github-review-key (below).
- Upgrades.
git pull && cargo xtask deploy acme; migrations apply first. Hosts and agents tolerate a control plane one version ahead, and hosts update themselves to its version within minutes (Host updates). - Backups. D1 keeps 30 days of point-in-time history (Time Travel):
npx wrangler d1 time-travel restore. For a copy,npx wrangler d1 export. Session event logs live in Durable Object storage, and so does each workspace's search index, which the sessions can build again: afterUPDATE sessions SET search_indexed = 0the hourly job has them do it. - Logs.
npx wrangler tail fctlstreams the Worker's logs; observability is enabled in the rendered config.fctl host logs, on a runner, shows its host service's. - Costs. The control plane is request- and storage-priced on Cloudflare; the work happens on your hardware and your Claude subscriptions. fctl never makes Anthropic API calls.
- Removing it.
fctl host uninstallon each runner first, while the control plane can still stop their sessions (--forcestops the ones that run), thennpx wrangler delete fctlandnpx wrangler d1 delete fctl.
Open the setup link to create the account of the primary instance owner,
then sign in and create your first workspace. Then invite your team under
Admin → People (each address on its own, and to your workspaces at the
same time), or allow them under Admin → Access: exact emails (which
also creates invite links) or whole domains (*@acme.dev), whose people
then sign up with Google and join the workspaces that invite them. To require
two-factor sign-in of everyone, turn it on under Admin → Settings →
Sign-in (or fctl admin set --require-two-factor on); it seals each
authenticator app's secret with FCTL_MASTER_KEY. Passkeys belong to the
host name in FCTL_PUBLIC_URL: moving the instance to another host name
leaves them behind, and people add new ones. As the primary instance
owner, you can also make sensitive actions, such as deleting projects,
making access tokens and inviting admins, take a passkey or a code first,
under Admin → Settings → Sensitive actions (or fctl admin set --verify-sensitive-actions on). It is off by default, which may suit an
instance only you or a few people use; turning it off again asks you to
confirm it's you. To hold addresses for later, such as /w/home and
/p/home, reserve them under Admin → Settings → Reserved addresses (or
fctl admin reserve home): a new instance holds none but those fctl's own
pages use, and a workspace or project whose name makes a reserved one gets
a random ending instead (home-829d34).
People who haven't uploaded a picture show their Gravatar: the control
plane asks gravatar.com for each one by the SHA-256 hash of their primary
address, as they sign up and once a day after, and keeps a copy. Each
person can turn that off in their profile. To turn it off for everyone,
as for an instance that must not reach gravatar.com or whose privacy
policy doesn't name Gravatar, use Admin → Settings → Profile pictures
(or fctl admin set --gravatar off), which also removes the copies.
Each person can export their data once a week: an export reads everything
they have, every transcript too. To let them export more often, or less,
choose under Admin → Settings → Data exports (or fctl admin set --export-interval 1d; off lets them export at any time, and a year is
the longest wait).
With a GitHub App, projects link repositories, agents get short-lived tokens for their project's repositories only, and fctl can create new repositories. An instance has one app; each workspace works in the accounts it is installed on that are connected to the workspace. A second app, for review agents to post as, is optional (A review app).
The steps below use https://fctl.acme.dev as FCTL_PUBLIC_URL; put
yours in its place. GitHub delivers the app's webhook from the internet,
so it needs to reach that address. An instance it can't reach, such as one
served over plain http on a home network, still works with the app, but
sessions learn of merges and checks hours late, through the hourly lookup
(Pull request state and checks).
Reviews start only when someone asks for one (fctl review start), and
follow-ups, which need the webhook, don't come at all.
Under the organization that will own it, open Settings → Developer settings → GitHub Apps → New GitHub App and fill in the form from the top:
| Field | Value |
|---|---|
| GitHub App name | Your choice, such as acme-fctl: agents' commits, pull requests and comments show it. The end of its public link (github.com/apps/acme-fctl) is its slug |
| Homepage URL | https://fctl.acme.dev, or a page about your instance |
| Redirect URI (Callback URL in older forms) | https://fctl.acme.dev/api/v1/github/authorized. A workspace's admin signs in to GitHub there to connect an account, which shows fctl what they may connect |
| Request user authorization (OAuth) during installation | Off: fctl starts the sign-in itself |
| Enable Device Flow | Off |
| Setup URL | https://fctl.acme.dev/api/v1/github/installed |
| Redirect on update | On, so that people who install the app from a workspace, or give it more repositories, come back to the workspace to choose what it works in |
| Webhook: Active | On |
| Webhook URL | https://fctl.acme.dev/api/v1/github/webhook |
| Secret | A secret of your own (openssl rand -hex 32), which you give the control plane in step 3 |
| SSL verification | Enabled |
| Permissions | The repository permissions below; no organization or account permissions |
| Subscribe to events | The events below |
| Where can this GitHub App be installed? | Only on this account when workspaces work only in your organization. Any account lets workspaces use other accounts too (someone's own, a friend's organization), which can install it only then; you can change it later under Advanced → Make public |
Leave the other fields as GitHub sets them. Repository permissions:
| Permission | Access | Why |
|---|---|---|
| Contents | Read and write | clone, commit, push |
| Pull requests | Read and write | open and update pull requests |
| Issues | Read and write | agents can read and comment on issues |
| Workflows | Read and write | agents may edit .github/workflows |
| Checks | Read and write | agents can see CI results; review agents' checks |
| Actions, Commit statuses | Read | agents can see CI results |
| Administration | Read and write | only for New repository |
| Metadata | Read | required (GitHub ticks it itself) |
Events, which GitHub offers once the permissions above are chosen:
| Event | What fctl does with it |
|---|---|
| Pull request | keeps sessions' pull requests current (opened, merged, closed), starts reviews, and brings agents back when their pull requests merge or close |
| Check run, Check suite | shows checks as they change, and brings agents back when one fails |
| Status | the same for CI that reports commit statuses rather than checks |
| Issue comment, Pull request review, Pull request review comment | brings agents back when people mention them (Follow-ups) |
fctl uses no other permission or event. An app that has more works the
same: the tokens fctl gives sessions carry only the permissions under
What sessions get, whatever the app has, and it
answers deliveries of other events without acting on them. (app.fctl.sh's
own app, fctl-sh, has more, for work beyond fctl's.) Among its events,
Release, Workflow run and Merge queue entry aren't in use
yet, but a future version of fctl may use them.
Install the app on the organization (all repositories, or the ones agents may touch, including those they only read, such as private dependencies), from its public link or Install App in its settings. Installing it lets the instance mint tokens for that account, but no workspace uses it until one of its admins connects it (step 4).
Collect these from the app's settings on GitHub (General):
| Setting | Where it comes from |
|---|---|
GITHUB_APP_ID (app_id) |
App ID, at the top |
GITHUB_CLIENT_ID (client_id) |
Client ID, under it |
GITHUB_APP_SLUG (app_slug) |
the end of the Public link |
GITHUB_DEFAULT_OWNER (default_owner) |
the organization New repository creates repositories in |
GITHUB_APP_PRIVATE_KEY |
Private keys → Generate a private key, which downloads a .pem file |
GITHUB_CLIENT_SECRET |
Client secrets → Generate a new client secret |
GITHUB_WEBHOOK_SECRET |
the webhook's secret from step 1 |
The client secret signs workspace admins in to GitHub to connect accounts; reading their installations and repositories needs only the Metadata permission above, so the app asks for no more.
On your own machine, set the seven variables (with Kubernetes, the key in
the fctl Secret:
--from-file=GITHUB_APP_PRIVATE_KEY=acme-fctl.private-key.pem) and start
the server again.
On Cloudflare, fill in the instance file:
[github]
app_id = "123456"
app_slug = "acme-fctl"
client_id = "Iv23li…"
default_owner = "acme" # where "New repository" creates repositoriesThen store the key and the two secrets and redeploy:
cargo xtask secrets acme --github-key ~/Downloads/acme-fctl.private-key.pem
echo "$GITHUB_CLIENT_SECRET" | cargo xtask secrets acme --set GITHUB_CLIENT_SECRET
echo "$GITHUB_WEBHOOK_SECRET" | cargo xtask secrets acme --set GITHUB_WEBHOOK_SECRET
cargo xtask deploy acmeThe key is stored as PKCS#8, which WebCrypto imports (cargo xtask secrets
and the server convert it); it never reaches a host or a sandbox. fctl
reads it also on one line, as a form can store it, its line breaks turned
into spaces, dropped or written as \n. Without the client secret, a
workspace keeps the accounts it has but can't connect a new one, and its
GitHub tab says so.
A workspace's admin connects the account under the workspace's GitHub
tab (or fctl workspaces connect) and chooses the repositories it works
in (Which workspace uses which account).
Projects then link those repositories. Admin → GitHub shows the app's
installations, and the app's Advanced → Recent Deliveries on GitHub
show each webhook delivery with the control plane's answer: the ping
GitHub sent as the app was created got 501 if the control plane didn't
have the secret yet; Redeliver it to see {"ok":true}.
With the webhook's secret set, the control plane takes the deliveries GitHub signs with it, and answers others with 401 (without the secret, 501). For each pull request a session lists that one concerns, it asks GitHub what became of it, so session lists and pages show its state (open, draft, queued, merged or closed) and its checks as they change, whatever the session is doing. It also asks about each pull request a session finds, and once an hour about up to 500 open ones it hasn't heard of for six hours, which brings sessions from before the webhook up to date at the first run after you set the secret. It asks about 50 at a time, for one point of the installation's GraphQL rate limit (5,000 an hour, which your agents share). In repositories whose pull requests have so many checks that GitHub gives up on 50 at once, it asks about 10 at a time, or one at a time, for a point each. It leaves the rest for the next hour while the installation has fewer than 1,000 points left, and after five minutes of lookups. Without the secret, sessions show what their agents last looked up, without checks.
The webhook also starts the reviews of projects whose review agents are on (Architecture): a pull request opened, pushed to or marked ready gets one. Their checks need the app's Checks permission to be Read and write: for an app made before, change it under Permissions & events, and each installation's owners accept it (GitHub asks them). Until then reviews post, without a check, and the project's Reviews tab says why.
To turn the webhook on for an app made before, edit it on GitHub: under
General, tick Active under Webhook and fill in its URL and
secret; under Permissions & events, subscribe to Pull request,
Check run, Check suite and Status. Installations accept
nothing new: the app reads pull requests, checks and commit statuses
already. Advanced → Recent Deliveries shows
each delivery and the control plane's answer: {"ok":true,"sessions":1}
when a session heard of it.
Subscribe to those four events before you rely on the webhook. When a pull request merges or closes while Pull request isn't subscribed, or the webhook is off, GitHub sends nothing about it, and its sessions keep showing it as it was until the hourly job looks it up again. The job skips pull requests the control plane looked up in the last six hours, and a delivery about a pull request's checks counts as a lookup: one that merges soon after its last check run shows as open for six to seven hours after that run.
To bring such a pull request up to date at once, make GitHub send a
delivery that names it, which makes the control plane look it up: add a
label to the pull request and remove the label again (with Pull request
subscribed), or redeliver an earlier delivery under
Advanced → Recent Deliveries (GitHub keeps them for three days). On
Cloudflare you can also have the next hourly run look up every pull
request that isn't merged or closed, by clearing when the control plane
last did (fctl is the database_name in your instance file):
npx wrangler d1 execute fctl --remote --command \
"UPDATE pull_request_sessions SET checked_at = NULL
WHERE state IS NULL OR state NOT IN ('merged', 'closed')"Projects can bring their agents back to the pull requests they opened
(Architecture): when someone who can write
to the repository mentions @fctl or @agent in a comment, when a check
fails, when a pull request conflicts with its base branch, when it merges
or closes. New projects start with all of them on, and each project turns
them on or off in its Settings, or with fctl projects set --follow-ups
(projects made before they did keep what they stored); they need the webhook's secret, and for
comments the app subscribed to Issue comment, Pull request review
and Pull request review comment. For an app made before, subscribe to
them under Permissions & events: installations accept nothing new,
since the app reads and writes issues and pull requests already. Failed
checks, conflicts and merges come with the events above: GitHub says
nothing when a pull request starts to conflict, so after each merge into a
repository the control plane asks it about the other pull requests there
whose projects follow up on conflicts, for a point of the installation's
rate limit, and again, a point at a time, while GitHub works out its
answer. Advanced → Recent Deliveries answers {"ok":true,"follow_ups":1}
for a comment that reached an agent, and fctl puts an 👀 reaction on it.
A workspace's admins connect accounts under the workspace's GitHub tab,
or with fctl workspaces connect. Connecting sends them to GitHub to sign
in with the app, which shows fctl what they may give the workspace: every
repository of their own account, or of an organization whose every
repository the app reaches and they administer all of (as its owners do);
otherwise the repositories they administer. Back on the tab, they choose
which of those the workspace works in. So nobody connects another account's
repositories to their workspace — GitHub won't let them — and an instance
admin, who holds the app's key, connects nothing into a workspace either.
Installing the app on an account from the tab, or giving it more
repositories there, brings them back to the same choice through the same
sign-in. Repositories named with --repo are connected without the
choice, where GitHub says they may be. New repositories go into
default_owner when the workspace works in all of it, otherwise into an
organization the workspace works in all of; the app can create repositories
in organizations, not in people's own accounts.
Under Admin → GitHub (fctl admin github), an instance admin sees the
app's installations and the workspaces that use each, and can take an
account from a workspace (fctl admin github remove <account> <workspace>), which its running sessions lose within the hour. Only the
workspace's own admins connect it again.
A project has a primary repository and may list more, each read only or
read and write, or let agents read every repository of the account that the
app can reach (owner/*, within what its workspace may use); they all
belong to one account, whose installation mints the tokens. Each session gets up to two, valid for an
hour and refreshed through its host:
| Token | Repositories | Permissions |
|---|---|---|
| Write | the primary repository and those listed as read and write | contents, pull requests, issues and workflows: write; checks, actions, commit statuses: read. For an agent that comments: pull requests and issues: write; contents, checks, actions, commit statuses: read |
| Read only | those listed as read only, or the account's (owner/*); for an agent that only reads, all of them |
contents, pull requests, issues, checks, actions, commit statuses: read |
Issues are on them only where the project's ticket tracker says
(Architecture): GitHub Issues of the
repositories its agents write to, as above; of one repository, issues
only on a write token for that repository alone; no tracker, or Linear,
none at all. For a project that keeps its agents to one repository's
issues among several it writes to, the control plane reads and writes
that one's for them (fctl ticket), with a token for its issues alone
that it revokes once used. A session a maintainer starts to reach
GitHub's issues (fctl run --github-issues) gets them as above, whatever
the tracker says.
What a session's agent may do on GitHub, push, comment, triage or read, is its own choice within its project's setting (Architecture). The app needs no more permissions for it: to resolve review threads for agents that comment, the control plane mints a token that can write the thread's repository's contents and keeps it, and for agents that triage, one that writes the issues of the repository they label and comment in.
A review agent's session gets one token, which only reads, for the reviewed repository and the project's others. The control plane posts its review with a token of its own, for that repository alone: pull requests and checks: write, contents: read; and resolves fctl's earlier threads with one that can write contents. Both are the review app's where it's installed. Neither leaves the control plane, and both are revoked once used.
Agents may write only to repositories that are named: reading a whole
account is as far as an owner-wide grant goes. A listed repository the
installation no longer reaches (removed from it, renamed or deleted) is left
out of the tokens, and the session says so; a session whose primary
repository is out of reach does not start. People manage the list under the
project's Repositories tab or with fctl projects repos.
Review agents post their reviews as the GitHub App, which agents write
their own comments and replies as. With a second app, they post as that
one where it's installed, so that a pull request shows a review agent's
findings apart from what the agent that opened it answers (app.fctl.sh's
are fctl-review and fctl-sh). Where it isn't installed on an account,
or doesn't reach a repository, reviews post as the GitHub App, as before.
Skip it, and review agents work all the same.
-
Create a second app under the same organization, as in step 1 above, with a name and logo of its own. It signs nobody in and hears nothing from GitHub (the GitHub App's webhook tells fctl everything), so most of the form stays empty:
Field Value GitHub App name Your choice, such as acme-fctl-review: reviews show itHomepage URL https://fctl.acme.dev, or a page about your instanceRedirect URI None Request user authorization (OAuth) during installation Off Enable Device Flow Off Setup URL https://fctl.acme.dev/api/v1/github-review/installedRedirect on update On, so that people who install it from a workspace come back there Webhook: Active Off, with no URL or secret Permissions The repository permissions below; no organization or account permissions Subscribe to events None (GitHub offers none while the webhook is off) Where can this GitHub App be installed? As for the GitHub App, so that the same accounts can install it Repository permissions:
Permission Access Why Pull requests Read and write post reviews and their summaries, mark findings fixed, sum pull requests up in their descriptions Checks Read and write the fctl reviewcheckContents Read and write resolve fctl's earlier threads, as GitHub asks; without it, the GitHub App resolves them Metadata Read required (GitHub ticks it itself) -
Generate a private key (General → Private keys), and tell the control plane about the app: its App ID and the slug at the end of its Public link. On your own machine, set
GITHUB_REVIEW_APP_ID,GITHUB_REVIEW_APP_SLUGandGITHUB_REVIEW_APP_PRIVATE_KEY, and start the server again. On Cloudflare, add it to the instance file's[github]:[github.review] app_id = "234567" app_slug = "acme-fctl-review"
Then store the key and redeploy:
cargo xtask secrets acme --github-review-key ~/Downloads/acme-fctl-review.private-key.pem cargo xtask deploy acme -
Install it on the accounts the GitHub App is installed on. Nobody connects it: fctl uses it only on repositories a workspace connected through the GitHub App, so installing it is all it takes. Each workspace's GitHub tab says which app posts the reviews on each of its accounts, and its admins install the review app there from the tab, on the repositories the workspace connected (
fctl workspaces review-appprints the links); a project's Reviews tab says which app its reviews post as. When fctl can't sign in to GitHub as the review app, because it can't readGITHUB_REVIEW_APP_PRIVATE_KEYor GitHub refuses the key, every review posts as the GitHub App, and the GitHub tab says why.
Which app writes a review is settled as the review starts, since only the app that created a check may change it: a review that started before the app was installed finishes as the GitHub App. The findings either app posted are fctl's to mark fixed, resolve and sum up later, each comment edited by the app that wrote it.
With a Linear app, teams that track their work in Linear delegate issues to fctl there: assigning one to fctl's agent, or mentioning it in a comment, starts a session of the project that takes the issue's team, as the person who asked, which reports back in the issue (Architecture). An instance has one app, which each workspace's admins install in their Linear workspace from fctl. Delegations reach fctl only through the app's webhook, so Linear needs to reach your instance from the internet.
-
Create an OAuth application in a Linear workspace you administer (Settings → API → OAuth applications), and fill it in:
Field Value Application name Your choice: people assign issues to it and mention it by this name (app.fctl.sh's is fctl-sh)Developer name, Developer URL Who runs the instance, shown as Linear asks to install the app Callback URLs (Redirect URIs) https://fctl.acme.dev/api/v1/linear/installed: workspaces install the app there, and people link their Linear accountsPublic On (Publicly available), so that Linear workspaces other than yours can install it; off, only yours can Webhooks On Webhook URL https://fctl.acme.dev/api/v1/linear/webhookEvents Agent session events (delegations, mentions, replies and stops), OAuth authorization events (Linear says fctl's app was uninstalled from a Linear workspace) and Permission changes (its admins changed the teams it sees) fctl uses no other events: they only send it every change in every Linear workspace that installs the app, which it answers without acting on. (app.fctl.sh's app subscribes to more, such as issues and comments, for work beyond fctl's.)
Or create it from a manifest with these, whose resource types are the three events above:
{ "$schema": "https://linear.app/.well-known/oauth-app-manifest.schema.json", "schemaVersion": "1.0.0", "distribution": "public", "oauth": { "client_name": "acme-fctl", "redirect_uris": ["https://fctl.acme.dev/api/v1/linear/installed"], "grant_types": ["authorization_code"] }, "webhook": { "url": "https://fctl.acme.dev/api/v1/linear/webhook", "resourceTypes": ["AgentSessionEvent", "OAuthAuthorization", "PermissionChange"] } }fctl asks for its scopes as workspaces install it:
read,write,app:assignable(people assign it issues, which delegates them) andapp:mentionable(people mention it), as Linear's own agent (actor=app); andreadalone, revoked at once, when someone links their account. The app needs no developer token. -
Tell the control plane about it: the Client ID and Client secret under OAuth credentials, and the Signing secret under Webhooks. On your own machine, set
LINEAR_CLIENT_ID,LINEAR_CLIENT_SECRETandLINEAR_WEBHOOK_SECRET(the signing secret) and start the server again. On Cloudflare, add the client ID to the instance file and the secrets to the Worker:[linear] client_id = "8f6a…"
echo "$LINEAR_CLIENT_SECRET" | cargo xtask secrets acme --set LINEAR_CLIENT_SECRET echo "$LINEAR_WEBHOOK_SECRET" | cargo xtask secrets acme --set LINEAR_WEBHOOK_SECRET cargo xtask deploy acme
-
A workspace's admin connects their Linear workspace under the workspace's Linear tab, or a project's Tickets tab: Linear asks one of its admins to install the app and to choose the teams it sees. Each project then chooses its teams. People link their Linear accounts (Settings, the project's Tickets tab, or Linear's own button the first time they delegate an issue), and fctl starts sessions only for people it knows there, as them.
The webhook takes deliveries signed with its secret within a minute of
their time, and answers others with 401 (without the secret, 501). Each
delivery is answered at once, as Linear wants within five seconds, and
fctl answers in the agent session within ten. Linear's Recent
deliveries, under the app's webhook, show what fctl answered:
{"ok":true}.
Let Linear's GitHub integration link the Linear workspace to your GitHub organizations too: sessions' branches carry their ticket's key, so it links their pull requests to the tickets. fctl moves the tickets to In Progress, In Review and Done itself as their sessions work, which each project's Tickets tab can turn off where a team's own automation in Linear moves them otherwise. fctl hears of merges through the GitHub App's webhook (Pull request state and checks).
Create an OAuth client of type Web application in the Google Cloud
console, with the authorized redirect URI
https://fctl.acme.dev/api/v1/auth/google/callback (your FCTL_PUBLIC_URL
followed by /api/v1/auth/google/callback; Google takes http ones only for
localhost).
On your own machine, set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET and
start the server again. On Cloudflare:
[google]
client_id = "1234-abc.apps.googleusercontent.com"echo "$GOOGLE_CLIENT_SECRET" | cargo xtask secrets acme --set GOOGLE_CLIENT_SECRET
cargo xtask deploy acmePeople can then link Google under Settings, and allowlisted people can sign up with it.
Without email, admins copy invite and password-reset links from the app. To send them, use Resend with a verified domain. With email, the links go only to the addresses they are for, and admins don't see them: a reset link signs in as its person, and an invitation's link joins the workspaces that invited its address.
On your own machine, set RESEND_API_KEY and FCTL_EMAIL_FROM
(Acme factory <fctl@acme.dev>) and start the server again. On Cloudflare:
[email]
from = "Acme factory <fctl@acme.dev>"echo "$RESEND_API_KEY" | cargo xtask secrets acme --set RESEND_API_KEY
cargo xtask deploy acmeAn instance asks search engines to keep out: its /robots.txt disallows
everything but /llms.txt. To have its sign-in page found, set search_engines = true in
its file in deploy/instances on Cloudflare, or FCTL_SEARCH_ENGINES=1
for the server. /robots.txt then allows the sign-in page (and the early
access page where there is one), /llms.txt, /sitemap.xml, /favicon.ico,
/favicon.svg, /icon-maskable-512.png (the favicon search results show)
and /apple-touch-icon.png, and names /sitemap.xml, which lists the
pages, for Google Search Console and the like.
Everything else stays disallowed, and needs signing in anyway.
A runner is a machine that runs sessions, with the fctl host service on
it. On each machine that runs agents (the one that runs the control plane
can be one of them), follow
Getting started → Bring a machine online:
curl -fsSL https://fctl.sh/install | sh
fctl login fctl.acme.dev # or with its scheme: fctl login http://192.168.1.20:8787
fctl host installMachines that autoscaling makes, or that nobody signs in on, enroll with a workspace's enrollment token instead, which its admins make on its Runners tab (Many machines and autoscaling):
FCTL_SERVER=https://fctl.acme.dev FCTL_ENROLL_TOKEN=fctl_et_… fctl host installThe control plane enrolls them, holds the ones that wait for approval and removes the ones that go, in its own code and D1, the same with this image as on Cloudflare: nothing to set up for it.
Hosts download their release from https://fctl.sh/releases by default; set
download_base in host.toml to mirror them elsewhere (fctl host image build installs fctl in the default image from there too). They pull the
sandbox image from ghcr.io/hyperstructure/sandbox; set image to use a
copy in your own registry (Sandbox images). A runner runs
any image a project chooses until its owner lists the ones it allows
(allowed_images), and builds images from no repository until they list
some (allowed_builds): set both on machines that run other people's
projects (Which images a host runs).
- Notifications need no setup and no push provider account: the control
plane makes its Web Push (VAPID) key the first time someone opens
Settings → Notifications, and keeps it in D1, sealed with the master key.
Browsers allow notifications only from
https://origins (andhttp://localhost). - Published screenshots stay off until an admin allows them (Admin →
Settings,
fctl admin set --publish-screenshots on) and a project turns them on. Its agents then publish screenshots for pull requests at links on your instance's domain, which anyone who has one can open until they expire (30 days unless the project chooses 24 hours to 60 days). If your instance has a privacy policy, say so there. GitHub shows them in pull requests only when its image proxy can fetch them, so only from an instance it can reach.