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"

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

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:

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:

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

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.