The HTTP API
The web app and the CLI do everything through one HTTP API, and so can any program: start sessions, message their agents, read their transcripts, run schedules, keep a project's memory. This page describes how to call it and the endpoints programs use most. The CLI is the easiest way in from a shell; fctl for agents has the patterns that go with either.
The request and response bodies are the Rust types in
crates/fctl-proto/src/api.rs, with
sessions in session.rs and events
in event.rs, and as TypeScript in
web/src/gen. Their comments are the reference for every
field. The API grows by adding fields, which are optional: ignore the ones
you don't know.
Calling it
export FCTL_SERVER=https://app.fctl.sh FCTL_TOKEN=fctl_pat_…
curl -sS -H "Authorization: Bearer $FCTL_TOKEN" "$FCTL_SERVER/api/v1/sessions?status=active"- Base URL: the instance's address followed by
/api/v1. - Authentication:
Authorization: Bearer <token>, with an access token (fctl_pat_…, limited to what it is for) or the CLI's own. The web app's cookie works only from the app itself. - Bodies are JSON (
Content-Type: application/json), except file uploads, which areapplication/octet-stream. - Ids have a type prefix:
ses_sessions,prj_projects,wsp_workspaces,sch_schedules,srn_schedule runs,mem_memories,rtr_retrospectives,att_attachments,dlv_messages,usr_people,rnr_runners. Paths take a project's or workspace's slug or id, and a session's id (project#numberis the CLI's; it looks the number up inGET /sessions?project=…). - Times are milliseconds since the Unix epoch.
- Absent fields: optional fields are left out rather than sent as
null, and lists that are empty may be left out too. - Paging: lists that page return a
next_beforeto pass as?before=for the next page, and take?limit=.
Errors
A request that fails answers with an HTTP status and a body:
{"code": "not_found", "message": "Session not found"}code is for programs, message for people. field names the request
field a validation error is about, and retry_after the seconds to wait
after rate_limited.
| Status | code |
|---|---|
| 400 | bad_request, validation |
| 401 | unauthorized (no token, or one that no longer works), invalid_credentials |
| 403 | forbidden (not allowed: the role or the token's limits), access_denied, two_factor_setup_required (the instance requires two-factor sign-in and the account has none yet), verification_required (a sensitive action that the token must confirm first: fctl account verify) |
| 404 | not_found, also for what exists but the caller may not see |
| 409 | conflict (a message to a failed session, a resume of one that runs…), setup_required |
| 429 | rate_limited |
| 501, 503 | not_configured (the instance lacks what it needs, such as a GitHub App), unavailable (GitHub, a runner) |
| 500 | internal |
Sessions
Start one
POST /api/v1/projects/{project}/sessions, for people who start sessions in
the project and tokens that run sessions there:
curl -sS -H "Authorization: Bearer $FCTL_TOKEN" -H "Content-Type: application/json" \
-d '{"prompt": "Fix the flaky login test and open a pull request", "github_access": "push"}' \
"$FCTL_SERVER/api/v1/projects/my-app/sessions"| Field | What it asks for |
|---|---|
prompt |
the task (may be empty when attachments has files) |
title |
default: the task's first line; the agent renames the session once it understands the work |
branch, base_branch |
default: fctl/<number>-<title>, from the project's default branch |
model, effort |
default: the project's; opus, sonnet, haiku, fable or a model's id, and low, medium, high, xhigh, max |
github_access |
push, comment or read: at most what the project and the token allow, which it gets when absent |
github_issues |
true to reach GitHub's issues whatever the project's tracker (project maintainers) |
attachments |
ids of files uploaded to POST /api/v1/projects/{project}/attachments |
It answers with the session, queued. A session is:
| Field | What it says |
|---|---|
id, number, project_id, project_slug, workspace |
who it is: my-app#12 is project_slug#number |
title, description |
what it works on, as its agent or people say |
status, status_reason, resume_at |
queued, provisioning, preparing, running, idle, paused, stopping, stopped or failed, and why |
created_by, created_at, started_at, ended_at, last_activity_at, updated_at |
who and when |
branch, base_branch |
its branch and where it started from |
pull_requests |
every pull request its agent opened or found: url, number, repo, title, state (open, draft, queued, merged, closed), checks (passed, failed, pending) |
model, effort, github_access, github_issues |
what it runs with |
stats |
turns, tool calls, changed files, additions and deletions, tokens, context |
role, following, archived |
the caller's role in it, whether they follow it, and whether it is archived |
schedule, ticket, review |
the schedule, ticket or reviewed pull request it started from |
remote_control |
its Remote Control link, for the person it runs for |
Read them
GET /api/v1/sessions: the sessions the caller sees, newest first, without review agents' and archived ones.?project=,?workspace=,?status=active(queued to stopping),?status=ended(stopped or failed) or one status,?mine=1(started by the caller),?reviews=1,?archived=1,?limit=(50 unless set, at most 200),?before=(acreated_at). Answers{"sessions": […], "next_before": …}.GET /api/v1/sessions/{id}: one session.GET /api/v1/search?q=…: the sessions whose title, description, messages or agent's replies have the words, with?workspace=,?project=and?limit=(20, at most 50). Each result has thesession, up to threematches(kind,seq,text,ranges) and acount.
Message, interrupt, stop, resume
| Request | Body | What it does |
|---|---|---|
POST /api/v1/sessions/{id}/messages |
{"text": "…", "client_id": "…", "attachments": [...]} |
reaches the agent at its next step; resumes a stopped session on the subscription it ran on; 409 for a failed one. Answers {"delivery_id": "dlv_…"} |
POST /api/v1/sessions/{id}/interrupt |
stops the current turn | |
POST /api/v1/sessions/{id}/stop |
ends the session; its sandbox stays for a resume | |
POST /api/v1/sessions/{id}/resume |
{"prompt": "…", "attachments": [...]} |
starts a stopped or failed session again, on the caller's subscription; 409 while it runs |
PATCH /api/v1/sessions/{id} |
{"title": "…", "description": "…", "archived": true} |
renames, describes or archives it ("" clears the description) |
client_id is a key of your own: a message sent again with the same one
is delivered once, and answers with the same delivery_id, so retry with
it after a timeout. Files for a message go to POST /api/v1/sessions/{id}/attachments first.
Events
GET /api/v1/sessions/{id}/events returns a page of the transcript,
{"events": […], "has_more": true}, oldest first:
?after=<seq>: the events after that one (from the start without it); page on with the lastseqwhilehas_more.?before=<seq>: the events before that one, the newest of them.?limit=: 200 unless set, at most 1,000.
An event is {"seq": 21, "ts": …, "kind": "…", "data": {…}}. The kinds are
user_message, claude (Claude Code's stream-json messages as it wrote
them: assistant, user, system, result, rate_limit_event),
status, git, log, notice, remote_control and interrupt;
fctl for agents says what each holds. A
turn ends with a claude event whose data.type is result, whose
data.result is the agent's last message.
To wait for the agent's reply to a message, note the newest seq before
sending it, then read the events after it, page by page, until one is a
result:
API=$FCTL_SERVER/api/v1
auth=(-H "Authorization: Bearer $FCTL_TOKEN")
last=$(curl -sS "${auth[@]}" "$API/sessions/$id/events?before=999999999&limit=1" | jq '.events[-1].seq // 0')
curl -sS "${auth[@]}" -H "Content-Type: application/json" \
-d '{"text": "Also add a regression test", "client_id": "nightly-41-tests"}' "$API/sessions/$id/messages"
reply=
while [ -z "$reply" ]; do
page=$(curl -sS "${auth[@]}" "$API/sessions/$id/events?after=$last&limit=1000")
reply=$(jq -r '[.events[] | select(.kind == "claude" and .data.type == "result")] | last | .data.result // empty' <<<"$page")
last=$(jq --argjson last "$last" '[.events[].seq] | max // $last' <<<"$page")
[ -n "$reply" ] || [ "$(jq .has_more <<<"$page")" = true ] || sleep 20
done
echo "$reply"Check GET /api/v1/sessions/{id} too while you wait: a failed session
takes no more turns, and a paused one waits for its person's usage limit
to reset (resume_at).
Follow it live
GET /api/v1/sessions/{id}/ws opens a WebSocket (?after=<seq> after a
reconnect) with the same Authorization header. Frames are JSON with a
type:
- From the server:
snapshotfirst (the session, the caller'srole, the latestevents,has_more_before, the response in progress inlive, who is watching, whether the runner is connected), thenevents(new stored events),stream(Claude's streamed deltas, not stored),session(the summary changed),presence,runner_connected,result(the outcome of a client frame, by itsclient_id),errorandpong. - From the client:
send(client_id,text,attachments),interrupt(client_id),ping(the apps send one every 25 seconds; a viewer that stops pinging is closed after two minutes),typing, andvisibility({"type": "visibility", "visible": false}: a viewer counts as watching, so its person gets no notifications about the session, until it says otherwise).
The socket stays open when the session stops: close it yourself. The server closes it with 4403 once the caller may no longer see the session.
Share and follow
GET /api/v1/sessions/{id}/shares;POST({"email": "…", "role": "viewer" | "editor" | "owner"}, someone in its workspace, by the address they joined it as) andDELETE /api/v1/sessions/{id}/shares/{user}for the session's owners. Only the person who started it, or a workspace admin, shares ownership.PUTandDELETE /api/v1/sessions/{id}/follow: the caller's notifications about it.GET /api/v1/sessions/{id}/retrospectives: what its agent recorded.
Schedules
| Request | What it does |
|---|---|
GET /api/v1/projects/{project}/schedules |
{"schedules": […]}: each with id, name, prompt, cron or at (or neither: runs when asked), timezone, model, effort, base_branch, github_access, enabled, state (active, paused, done), paused_reason, problem, next_run_at, last_run, owner |
POST /api/v1/projects/{project}/schedules |
{"name", "prompt", "cron" or "at", "timezone", "model", "effort", "base_branch", "github_access", "enabled": false}: set one up, on the caller's subscription (people who start sessions in the project; tokens with full access) |
GET, PATCH, DELETE /api/v1/schedules/{id} |
read, change (absent fields stay; "" for the project's defaults; "enabled": false pauses it; "run_as_me": true takes it over), delete |
GET /api/v1/schedules/{id}/runs |
{"runs": […], "next_before": …}, newest first |
POST /api/v1/schedules/{id}/runs |
{"context": "…"} (at most 20,000 characters): run it now, on the caller's subscription |
A run is {"id": "srn_…", "outcome": "started" | "skipped" | "failed", "reason", "trigger": "time" | "person" | "token", "by", "session": {"id", "number", "title"}, "status"}. Runs that a token asks for give the agent
the context after the task, marked as information from outside fctl, not
instructions: send what another tool knows there, never in a task.
People and tools run one schedule at most 20 times an hour, and a paused
schedule runs not even then. Automation
shows a tool's request.
Projects
| Request | What it does |
|---|---|
GET /api/v1/projects |
the caller's projects (?workspace=) |
GET /api/v1/projects/{project} |
one, with its primary repo, default_branch, settings, session counts and the caller's role |
POST /api/v1/projects |
{"workspace", "name", "slug", "description", "repo": "owner/name" or "create_repo": {"name", "private", "owner"}, "settings"}: workspace admins |
PATCH /api/v1/projects/{project} |
{"name", "description", "repo", "default_branch", "settings", "archived"}: maintainers |
DELETE /api/v1/projects/{project} |
with its sessions, once none of them is active (409 until then): owners. It can't be undone |
GET, POST /api/v1/projects/{project}/repos, DELETE …/repos/{owner}/{name} |
the repositories its agents use: `{"repo": "owner/name" or "owner/*", "access": "read" |
GET, POST /api/v1/projects/{project}/members, PATCH, DELETE …/members/{user} |
its members: `{"email", "role": "owner" |
GET /api/v1/projects/{project}/secrets, PUT, DELETE …/secrets/{name} |
secrets as environment variables of its sandboxes: PUT takes {"value": "…"}; the list has names and who set them, never values (maintainers) |
GET /api/v1/projects/{project}/tickets |
its ticket tracker as it works now, and the sessions its tickets started |
settings in a PATCH replaces all of the project's settings, so send
them all: read the project, change what you need, and send its settings
back.
API=$FCTL_SERVER/api/v1
auth=(-H "Authorization: Bearer $FCTL_TOKEN" -H "Content-Type: application/json")
settings=$(curl -sS "${auth[@]}" "$API/projects/my-app" |
jq '.settings | .instructions = "Write the tests first." | .model = "opus" | .effort = "high"')
curl -sS "${auth[@]}" -X PATCH -d "{\"settings\": $settings}" "$API/projects/my-app"The settings: setup_script, instructions (what agents are told after
fctl's own), model, effort, image or image_build, cpus,
memory_mb, package_caches, env (not secret), idle_timeout_minutes,
auto_pull_request (agents open a pull request when they finish),
pull_request_descriptions (brief, balanced, full),
github_access, follow_ups (comments, checks, conflicts,
closed), tickets, remote_control, claude_ai_connectors,
credit_people, credit_claude, publish_screenshots,
screenshot_days, project_memory and retrospectives
(ProjectSettings describes each).
Memory, retrospectives and reviews
| Request | What it does |
|---|---|
GET /api/v1/projects/{project}/memories |
{"memories": […], "current": n}: current notes first, newest first, then stale ones; ?session= for one session's |
POST /api/v1/projects/{project}/memories |
{"text": "…", "replaces": "mem_…", "also_replaces": [...]}: a note for later agents (editors; tokens that run sessions) |
PATCH, DELETE /api/v1/memories/{id} |
{"text", "stale": true, "reason", "duplicate_of"}: change, mark stale or current again, delete (editors) |
GET /api/v1/projects/{project}/retrospectives |
newest first, ?outcome=completed / partial / blocked, ?limit=, ?before=; the first page counts outcomes |
GET, PATCH /api/v1/projects/{project}/review-agents |
its review agents: {"enabled", "run_as_me", "model", "effort", "instructions", "bots"} (owners turn them on, on their own subscription) |
GET /api/v1/projects/{project}/reviews |
its reviews, newest first: state (running, passed, commented, superseded, stopped, failed), the pull request, findings, review_url |
POST /api/v1/projects/{project}/reviews |
{"pull_request": "12"} (or owner/name#12, or its URL): review its head now |
Everything else
The rest of the API is what the web app and the CLI use for people,
workspaces, runners, Claude connections, notifications and administration:
/api/v1/workspaces/…, /api/v1/runners/…, /api/v1/me/…,
/api/v1/admin/…, /api/v1/auth/…. Access tokens with limits reach none
of it. The routes are listed in
crates/fctl-control/src/lib.rs, and
crates/fctl-client/src/api.rs, the
CLI's client, calls each with its types.