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--access runstarts sessions, messages and interrupts their agents, stops and resumes the sessions it started, and keeps projects' memory;readonly reads;fulldoes whatever its person may in its projects.-pand-wkeep it to some projects or workspaces,--githubcaps what its sessions' agents may do on GitHub, and--daysmakes it expire. A token with any limit never reaches the account, workspaces' people and settings, Claude connections or other tokens, so it can't make itself another. One without limits does everything its person can: keep those on people's own machines.fctl tokenprints the token once. Making one is a sensitive action: where the instance asks people to confirm it's them, it asks in the terminal, so a person makes it, not the program.- Sessions a token starts run as its person, on their Claude subscription,
and don't notify them;
fctl followdoes. fctl loginsigns a person in through the browser (--no-browserprints the link to open elsewhere). It needs the person: don't use a person's sign-in for unattended work.FCTL_SERVERandFCTL_TOKENtake precedence over whatfctl loginsaved in~/.config/fctl/(FCTL_CONFIG_DIRmoves that).--servertakes precedence over both.- Revoke a token that may have leaked at once:
fctl tokens revoke NAMEor Settings → Access tokens.
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:
- Always pass
-d(--detach) tofctl run, then wait as below. - Use
fctl logswithout-f. To stream for a while, bound it:timeout 600 fctl logs -f SESSION --json. - While a viewer is connected (
fctl attach,fctl logs -f, the web app with its tab visible), its person counts as watching the session and gets no notifications about it.
Other things that wait for input:
- stdin.
fctl runandfctl sendwithout message text, andfctl schedules addwithout-mor--task-file, read stdin to its end when it isn't a terminal. Where stdin stays open, as in some agent harnesses, they hang. Pass the text as an argument, or-f FILEtofctl run, or pipe it in yourself (printf '%s' "$text" | fctl send SESSION). - Confirmations. Deleting asks you to type the name in a terminal;
without one, pass it:
fctl projects delete my-app --confirm my-app(alsofctl workspaces delete --confirm NAME,fctl account delete --confirm EMAIL,fctl admin delete-user --confirm EMAIL,fctl admin delete-workspace --confirm NAME).fctl host uninstallwith sessions running needs--waitor--forcewithout a terminal. - Confirming it's you. Where the instance asks for it, sensitive actions
(making access tokens, adding or registering runners, inviting admins,
exporting data, deleting accounts, projects and workspaces) fail with
verification_requireduntil the token confirmed within the last 15 minutes:fctl account verify --code 123456with a code from the person's authenticator app. Limited tokens can't confirm, and of these actions reach only deleting a project, which they leave to a person.
Output and exit codes
- With
--json, a command prints one JSON document on stdout, in the shapes of the HTTP API:fctl run -d --jsonandfctl show --jsonprint a session (.id,.number,.status,.status_reason,.branch,.pull_requests),fctl sessions --jsonprints{"sessions": […], "next_before": …}. fctl logs --jsonprints one event per line (JSON Lines), with or without-f: read it withjq -sas an array, or line by line.- Errors go to stderr as text,
error: …, with or without--json. - Exit codes: 0 for success, 1 for an error (the API refused, nothing to
do, a network failure), 2 for a usage error (an unknown flag, a missing
argument).
--helpexits with 0. - Optional fields are left out when empty:
.pull_requestsis absent, not[], until the session has one (jq '.pull_requests // []').
Naming things
- A session is its id (
ses_01J…), its URL (https://app.fctl.sh/s/ses_…) orproject#number(my-app#12).project#numberlooks among the project's latest 200 sessions: scripts keep the id. - Projects and workspaces take their slug or id. Commands that take a
project use
-p, else the onefctl projects usechose; commands that take a workspace use-w, elsefctl workspaces use, else your only one. Sofctl sessionswithout-plists only the default project's sessions when one is set;-wlists a workspace's. - Schedules take their id (
sch_…) or name, memoriesmem_…, retrospectivesrtr_…, tokens their name ortok_….
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"- Turns take minutes to hours. Poll every 20 to 60 seconds, not faster:
each
fctl logsreads the whole transcript (the API reads it from an event on: Events). fctl show SESSION --jsonhas the branch, the pull requests with their state and checks (.pull_requests[] | {url, state, checks}), the model, and counters of turns, tool calls and changed files (.stats).- A session that waits for a message stops after its project's idle timeout (ten minutes unless the project says otherwise); a message resumes it, in the same sandbox and conversation while its runner still has them.
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
fctl send SESSION "…"reaches the agent at its next step, with-a FILEfor files (up to 10, of 20 MB each); a stopped session resumes for it, on the Claude subscription it ran on. Each call is a new message: through the API, aclient_idmakes a retried message arrive once.fctl interrupt SESSIONstops the current turn; the session waits for a message.fctl stop SESSIONends the session and keeps its sandbox;fctl resume SESSION "…"starts it again on your subscription, a failed one too, which a message doesn't.- A token that may comment or read on GitHub can only read a session whose agent started able to do more, so it can't steer an agent that pushes.
- Review agents' sessions take no messages.
- People can share a session with others in its workspace, and anyone who may message it can see, and have its agent hand over, what is in its sandbox: the project's secrets, its GitHub tokens and the Claude token of the person it runs for.
Things to avoid
fctl runwithout-d,fctl attachandfctl logs -fin tool calls and scripts: they don't return when the session ends.- Commands that read stdin (
fctl run,fctl sendandfctl schedules addwithout their text) where stdin stays open. - Starting sessions in a loop. Each session spends its person's Claude plan and takes a runner's slot (four each by default); the ones runners can't take queue. Fan out to a handful at a time and wait for them.
- Sending a message again because the first seemed lost: read the
transcript first, or send it through the API with a
client_id. - Passing text you didn't write as your instruction. What
fctl runandfctl sendcarry reaches the agent as the token's person's words. Text from issues, emails, chats and webhooks goes in a when-asked schedule'scontext(below), which the agent gets marked as information, not instructions. - More GitHub access than the task needs. Reviews, triage and planning run
with
--github commentor--github read; a project's maintainers can cap every session (fctl projects set --github comment). --envfor credentials: its values are not secret. Secrets go in the project's secrets (its Secrets tab, orPUT /api/v1/projects/{id}/secrets/{name}); anything the agent runs can read both.- Pushing to a session's branch from outside while its agent works, force pushing it, or reusing it once its pull request merged: fctl pushes it after every turn until the merge, then no more. Message the agent, or start a new session.
- Deleting projects, workspaces or accounts, or uninstalling and draining hosts, unless a person asked for exactly that.
- Polling faster than every 20 seconds, or reading whole transcripts in a tight loop.
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.
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 90The 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,skippedorfailed),.reason, and.session.idof 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--crontakes five fields or@hourly,@daily,@weekly,@monthlyand@yearly, at most every 15 minutes;--tzis the machine's time zone unless given, UTC in most CI.--attakes2h,30m,1dor a time with its UTC offset.--pausedsets it up without running it.- Runs go on the subscription of the schedule's owner, who set it up; a change by someone else moves it to theirs. A run is skipped while the last one still works, or while the owner's plan is at its limit.
- Setting up or changing a schedule from a token takes
--access full, since its sessions start after the token is gone; aruntoken runs existing ones.
Linear
- 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). - A project maintainer chooses the team:
fctl workspaces linear teams, thenfctl projects set -p my-app --tickets linear --linear-team ENG(--linear-project "Mobile app"for one Linear project of it). - 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, closedEach 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 --jsonThe 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 statusfctl 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 --jsonTeach 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- The repository is at
~/workspace, on the session's branch; other repositories go in~/repos/<owner>/<name>; attached files are in~/attachments/.FCTL_SESSION_ID,FCTL_SESSION_URL,FCTL_SANDBOX_CPUSandFCTL_SANDBOX_MEMORY_MBsay where it is. - git and
ghare signed in with tokens for the project's repositories, as far as the session's GitHub access allows, refreshed each hour. - fctl pushes the branch after every turn until its pull request merges; agents commit as they go and never force-push.
- Notes are at most 2,000 characters, a project keeps 500, and a note replaces at most 20 others. A session keeps at most 50 retrospectives and publishes at most 100 screenshots of 5 MB. In an hour, an agent comments on tickets at most 30 times, files at most 20, changes at most 60 and reads its tracker at most 300 times.
- Agents in a session can't start other sessions: they hold no fctl credentials. A project secret with an access token would let them, on that token's person's subscription; treat that as giving every agent of the project that person's access.