fctl for agents

This page is for agents, scripts and other programs that drive fctl: a coding agent that hands work to fctl, a CI job, a bot, an issue tracker's automation. It says how to sign in without a person at the keyboard, which commands return and which keep running, how to wait for a session and read what its agent did, what to avoid, and how to set up the common flows. The last part is about the other side: what an agent working in an fctl session can do in its sandbox. llms.txt sums all of this up in one file, with fctl's vocabulary.

Everything here works the same against app.fctl.sh and an instance of your own: FCTL_SERVER says which. The CLI lists every command, and the HTTP API has the requests behind them.

Signing in

A program uses an access token that a person made for it, limited to what it is for, in FCTL_TOKEN:

fctl token "Nightly triage" --access run -p my-app --github comment --days 30   # a person runs this
export FCTL_SERVER=https://app.fctl.sh FCTL_TOKEN=fctl_pat_…
fctl whoami                      # whose it is, and what it may do

Commands that return, and commands that follow

Most commands do one thing and return. Three keep running:

Command Returns Keeps running
fctl run … -d once the session is created
fctl run … without -d following the session, also after it stops, until killed (Ctrl-C)
fctl attach SESSION until Ctrl-C, Ctrl-D or /detach in a terminal
fctl attach --read-only SESSION, fctl logs -f SESSION at once, if the session had already stopped or failed when it connected otherwise, also after the session stops
fctl logs SESSION, fctl show, fctl sessions, fctl send, fctl resume, fctl stop, fctl interrupt once done

So in scripts and tool calls:

Other things that wait for input:

Output and exit codes

Naming things

Waiting for a session

A session's status says where it is:

Status What it means What to do
queued waiting for a runner; status_reason says why wait a little; if no runner is online, or none allows its image or size, tell a person
provisioning, preparing its sandbox is being made; the setup script runs wait
running a turn is under way wait
idle the turn ended: it waits for a message, or for its background tasks (status_reason "Waiting for 2 background tasks"), which start a turn of their own when they end read the reply; message it, or leave it
paused its person's plan hit a usage limit; it continues by itself at resume_at wait, or tell a person
stopping, stopped its sandbox stopped (idle timeout, fctl stop, its runner went away); a message resumes it message it to carry on
failed status_reason says why fctl resume restarts it, on your subscription

Every turn ends with a result event from Claude in the transcript, which holds the agent's last message. Counting them tells a turn that ended from one that hasn't started yet, which the status alone doesn't right after a message:

turns() { fctl logs "$1" --json | jq -s '[.[] | select(.kind == "claude" and .data.type == "result")] | length'; }
reply() { fctl logs "$1" --json | jq -s -r '[.[] | select(.kind == "claude" and .data.type == "result")] | last | .data.result'; }
wait_turn() {   # until the session has more than $2 finished turns, or can't go on
  while [ "$(turns "$1")" -le "$2" ]; do
    case $(fctl show "$1" --json | jq -r .status) in failed|paused) return 1 ;; esac
    sleep 20
  done
}

id=$(fctl run -p my-app -d --json "Fix the flaky login test and open a pull request" | jq -r .id)
wait_turn "$id" 0 && reply "$id"

n=$(turns "$id")
fctl send "$id" "Also add a regression test"
wait_turn "$id" "$n" && reply "$id"

Reading a transcript

Each line of fctl logs --json is an event: {"seq": 21, "ts": …, "kind": "…", "data": {…}}, with seq counting up from 1 and ts in milliseconds since the epoch. The kinds:

kind data
user_message what a person, fctl or a tool sent: text, author, source (web, cli, api, remote_control, system), files, and what brought it (a follow-up, a review, a ticket)
claude a message of Claude Code's stream-json output, as Claude Code wrote it: type is system, assistant (text and tool calls in .message.content), user (tool results), result (the end of a turn: .result is the final text, .is_error), rate_limit_event
status status, reason, resume_at
git the repositories' state: branch, base, head, ahead, behind, files, uncommitted, pushed, last_commit, pull_requests, and the other repositories in repos
log output of provisioning, the setup script and the supervisor
notice something to notice: a usage-limit pause, a runner that went away, a note added to memory, a retrospective, a follow-up held back
remote_control, interrupt Remote Control became available or not; someone interrupted a turn

Some useful filters:

fctl logs "$id" --json | jq -r 'select(.kind == "claude" and .data.type == "assistant") | .data.message.content[] | select(.type == "text") | .text'
fctl logs "$id" --json | jq -c 'select(.kind == "claude" and .data.type == "assistant") | .data.message.content[] | select(.type == "tool_use") | {name, input}'
fctl logs "$id" --json | jq -s 'map(select(.kind == "git")) | last | .data'

fctl logs SESSION without --json prints the transcript as people read it, and fctl search WORDS --json finds sessions by what people and agents wrote.

Steering a session

Things to avoid

Recipes

Hand work to fctl and collect the result

Waiting for a session has the script. Variations:

fctl run -p my-app -d --json --github read "Explain how sessions are scheduled, with file references"
fctl run -p my-app -d --json -a screenshot.png "Make the header match this"
fctl run -p my-app -d --json -f task.md --title "Move to Postgres" --branch db/postgres --base develop
fctl run -p my-app -d --json --model opus --effort high "…"

The pull request is in .pull_requests once the agent opened one; its URL, state and checks come with it.

Several tasks at once

Each session has its own sandbox, branch and pull request, so independent tasks run side by side:

while IFS= read -r task; do
  fctl run -p my-app -d --json "$task" | jq -r .id
done < tasks.txt > sessions.txt
fctl sessions -p my-app --active --json | jq -r '.sessions[] | "\(.number) \(.status) \(.title)"'

Runners take as many sessions as their capacity allows, and the rest wait in the queue. Tasks that touch the same files make pull requests that conflict; with conflict follow-ups on, each agent merges the base branch in once the other merges.

From any tool

Any program that can make an HTTP request starts agents through a schedule that runs when asked: the schedule holds the task, and the program sends what it knows as context.

  1. A person sets up the schedule and a token:

    fctl schedules add "Triage an issue" -p my-app --when-asked --github comment \
      -m "Triage the issue that comes with this: label it, say what is missing to act on it, link duplicates and point at the code it concerns."
    fctl token "Issue triage" --access run -p my-app --github comment --days 90
  2. The program posts to the schedule's runs, with at most 20,000 characters of context:

    curl -sS -H "Authorization: Bearer $FCTL_TOKEN" -H "Content-Type: application/json" \
      -d '{"context": "Issue #41: The login page hangs on Safari\n\nSteps: …"}' \
      "$FCTL_SERVER/api/v1/schedules/sch_…/runs"

    The answer is the run: .outcome (started, skipped or failed), .reason, and .session.id of the session it started. From a shell: printf '%s' "$text" | fctl schedules run sch_… --context-file - --json.

The agent gets the task, a note that a tool started it and nobody watches it start, and the context inside <context>, marked as information from outside fctl, not instructions. Tools may run one schedule 20 times an hour; pausing the schedule (fctl schedules pause sch_…) stops them all. A tool that can't add a header to its webhooks needs a small relay that adds it and picks out the fields: a serverless function, or a no-code tool such as Zapier, Make or n8n. Automation has more.

From GitHub Actions

Automation has a workflow that reviews each pull request with a token in the repository's secrets, keeping one session per pull request, found by its branch. The same shape triages new issues:

on:
  issues:
    types: [opened]
jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - run: |
          curl -fsSL https://fctl.sh/install | FCTL_NO_COMPLETIONS=1 sh
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"
      - env:
          FCTL_SERVER: https://app.fctl.sh
          FCTL_TOKEN: ${{ secrets.FCTL_TOKEN }}
          ISSUE: ${{ github.event.issue.number }}
        run: |
          fctl run -p my-app --github comment -d "Triage issue #$ISSUE: label it, say what is missing to act on it, and point at the code it concerns."

Pass what comes from GitHub (titles, bodies) through env, never ${{ }} inside the script, where it would run as shell. Issue bodies come from anyone: send them as a when-asked schedule's context instead of in the task.

On a schedule

fctl schedules add "Weekly dependencies" -p my-app --cron "0 9 * * 1" --tz Europe/Stockholm \
  -m "Update the dependencies that have new patch or minor releases, run the tests and open a pull request."
fctl schedules add "Release notes" -p my-app --at 2026-10-30T16:00+01:00 -m "Draft the release notes."
fctl schedules -p my-app --json          # each with .next_run_at and .last_run
fctl schedules runs "Weekly dependencies" -p my-app

Linear

  1. A workspace admin who administers the Linear workspace connects it: fctl workspaces linear connect (opens Linear, where they install fctl's app and choose the teams it sees).
  2. A project maintainer chooses the team: fctl workspaces linear teams, then fctl projects set -p my-app --tickets linear --linear-team ENG (--linear-project "Mobile app" for one Linear project of it).
  3. Each person links their Linear account once: fctl account linear connect.

Then assigning an issue to fctl in Linear (which makes fctl its delegate) or mentioning fctl in a comment starts a session of the project as that person, on their subscription. The issue moves to the team's first started state, the branch carries its key (fctl/12-eng-123-fix-the-login-loop), and the agent's steps, pull requests and replies show in the issue's agent session, where replies reach the agent. With the explore label the agent replies with a plan and changes nothing until someone asks. In the sandbox, the agent reads and writes the team's issues with fctl ticket.

Review agents and follow-ups

fctl review settings -p my-app --on --model opus --effort high   # a project owner: on their subscription
fctl review settings -p my-app --instructions-file review.md
fctl review settings -p my-app --bots dependabot,renovate         # apps whose pull requests start reviews
fctl review start -p my-app 12                                    # review a pull request's head now
fctl review -p my-app --json                                      # reviews and what came of each
fctl projects set -p my-app --follow-ups all                      # comments, checks, conflicts, closed

Each new commit of a pull request in the project's writable repositories then gets a review; when an fctl agent opened the pull request, its session gets the findings, fixes what is real and answers in the threads. Follow-ups bring that agent back for @fctl comments from people who can write to the repository, failed checks, conflicts with the base branch and merges. Both need the instance's GitHub webhook (Self-hosting).

Set up a project

What a workspace admin, or a project's maintainer, can do from the CLI:

fctl projects create "My app" --repo my-org/my-app -w acme --slug my-app
fctl projects repos add my-org/my-lib --write -p my-app
fctl projects set -p my-app --setup-script @setup.sh --env NODE_ENV=test --idle-timeout 30
fctl projects set -p my-app --image ghcr.io/acme/agent:1      # or --build .devcontainer/devcontainer.json
fctl projects set -p my-app --cpus 8 --memory 16g --package-caches on
fctl projects set -p my-app --github push --pr-descriptions balanced --follow-ups all
fctl projects set -p my-app --tickets github
fctl projects show my-app --json

The project's instructions for agents, its model and effort, whether agents open a pull request when they finish, Remote Control, claude.ai connectors, secrets, members and shares are in the web app (the project's Settings, Secrets and Members tabs), or the API: Projects.

Bring a machine online

On the machine, by its owner: install a driver (Apple's container on macOS 26 with Apple silicon, then container system start; Lima; or Docker), then:

curl -fsSL https://fctl.sh/install | sh
fctl login app.fctl.sh
fctl host install --workspace acme          # or --token fctl_rt_… from the workspace's Runners tab
fctl host doctor && fctl host status

fctl host drain --wait empties it for maintenance and fctl host undrain takes sessions again. Getting started and Hosts have the rest.

Reports

A token with --access read reads and starts nothing:

fctl sessions --active --json
fctl search "flaky test" --json
fctl retro list -p my-app --outcome blocked --json
fctl memory -p my-app --json
fctl review -p my-app --json

Teach your agent to use fctl

To let a coding agent hand work to fctl, give it FCTL_SERVER and a limited FCTL_TOKEN in its environment and a few lines in its instructions (CLAUDE.md, AGENTS.md):

## fctl
Hand longer tasks to fctl (https://fctl.sh/llms.txt) instead of doing them here:
`fctl run -p my-app -d --json "<task>"` starts one; wait for its turn by counting
`result` events in `fctl logs <id> --json`, and read the pull request in `fctl show <id> --json`.
Never run `fctl run` without `-d`, `fctl attach` or `fctl logs -f`: they don't return.

Inside a session's sandbox

An agent that fctl runs finds what it needs in its system prompt and in ~/AGENTS.md, which ~/.claude/CLAUDE.md imports. Its sandbox has no fctl credentials: these commands reach the control plane through fctl-agent, for its own session and project only.

fctl edit --title "Fix the flaky login test" --description "Retries the redirect and tests it."
fctl memory                                  # the project's notes, now
fctl memory add "The e2e tests need Chromium: run npx playwright install chromium first."
fctl memory stale mem_… --reason "The script moved to xtask"
fctl retro list                              # this session's retrospectives, with their ids
fctl retro --outcome completed --worked-on "…" --approach "…" --challenges "…" --insights "…"
fctl retro update rtr_… --outcome completed --challenges "…"
fctl ticket list --search login              # where the project's tracker is Linear or one repository's issues
fctl ticket comment ENG-123 "…"
fctl publish after.png                       # where the project publishes screenshots
fctl resolve PRRT_…                          # agents that comment: resolve a review thread
fctl review threads && fctl review submit review.json   # review agents