Overview
While AFH is open it serves an API on 127.0.0.1 — the same one its own window uses. Anything you can do in the app you can do from code: create threads, send messages, read replies and diffs, set goals and to-do lists, approve actions, and schedule recurring jobs.
fetch.The app also serves an interactive reference generated from your exact version: Settings → Developer → Interactive reference (it opens /api/docs).
Quick start
- Create a key. In AFH open Settings → Developer and click Create key. Copy it — it's shown only once.
- Save it as an environment variable so it stays out of your code:
setx AFH_API_KEY "afh_your_key_here" - Run a task. AFH must be open. The SDK finds it on its own.
from afh_sdk import AFH
afh = AFH() # finds the running app, reads AFH_API_KEY
result = afh.run("Add a /health endpoint with a test, then run the tests",
directory=r"C:\code\my-api")
print(result["reply"])
print(result["todos"], result["cost"])import { AFH } from "./afh.mjs";
const afh = await AFH.connect(); // finds the running app, reads AFH_API_KEY
const result = await afh.run("Add a /health endpoint with a test, then run the tests", {
directory: "C:/code/my-api",
});
console.log(result.reply);curl -s http://127.0.0.1:PORT/api/v1/run \
-H "Authorization: Bearer $AFH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "Add a /health endpoint with a test", "directory": "C:/code/my-api"}'Authentication
Send your key in the Authorization header of every request:
Authorization: Bearer afh_3kX9…- Keys start with
afh_. AFH keeps only a SHA-256 hash in~/.afh/api-keys.json, so a lost key can't be shown again — create a new one. - Settings → Developer lists each key with when it was last used. Deleting a key stops it working immediately.
- API keys work on the local address only. Phones and other computers use Remote control, which has its own password.
- A key can do anything the app can, including running commands in your projects. Treat it like a password.
Finding the app
AFH picks a free port each time it starts and writes where it is to a discovery file while it runs:
{
"url": "http://127.0.0.1:53817",
"version": "1.8.7",
"pid": 18244,
"started": 1793270000000
}The SDKs read it for you. To always use the same address instead, set a Fixed port in Settings → Developer, or pass url / set AFH_URL. If you moved AFH's data folder with AFH_HOME, the file is there instead.
Errors
Failed requests answer with a JSON body {"error": "message"} and a status code:
| Status | Meaning |
|---|---|
400 | A field is missing or wrong — the message says which. |
401 | No key, or the key was deleted. |
403 | The request came through a host name other than 127.0.0.1/localhost. |
404 | No thread, job or key with that id. |
503 | The engine is restarting after a settings change. Retry in a second or two. |
The SDKs raise AFHError with status and the message.
Run a task
/api/v1/runThe one-call way: start a thread in a folder (or continue one), send the prompt, and wait until the agent is done — including any autopilot turns toward a goal.
| Field | Type | |
|---|---|---|
prompt | string | Required. What you want done. |
directory | string | The project folder for a new thread. Required unless you pass sessionID. |
sessionID | string | Continue this thread instead of starting one. |
model | string | "provider/model", e.g. "anthropic/claude-sonnet-5-5". Default: the app's default model. |
agent | string | An agent id from Settings → Prompts & agents. |
mode | string | full (default — no approvals, it just works), build (asks before risky actions) or plan (read-only planning). |
wait | boolean | Default true. false returns at once with the thread id. |
timeout | number | Seconds to wait, default 1800. The run keeps going after a timeout; done is then false. |
{
"sessionID": "ses_01J…",
"done": true,
"reply": "Added GET /health returning {\"ok\": true} and tests/test_health.py. All 42 tests pass.",
"todos": [{"content": "Add the route", "status": "completed"}, …],
"goal": null,
"cost": 0.0412,
"title": "Add /health endpoint"
}/api/v1/sessions/{id}/replyFor runs started with wait: false: {sessionID, running, reply} — the newest answer so far.
Threads
A thread (session) is one conversation in one project folder, with its messages, to-do list, goal and undo history.
| GET | /api/sessions?directory=… | Threads in a project, newest first. &archived=true for archived ones. |
| POST | /api/sessions | Create: {directory, title?, model?: {providerID, id}, agent?, mode?, worktree?}. worktree: true gives the thread its own git branch and folder. |
| GET | /api/sessions/{id} | The thread: title, folder, model, mode, cost, running. |
| PATCH | /api/sessions/{id} | Change title, model, agent, mode or archived. |
| GET | /api/sessions/{id}/messages | Every message with its parts: text, reasoning, tool calls and their results. |
| POST | /api/sessions/{id}/prompt | Send {text, files?: [paths]}. Returns at once; follow the run with events or /reply. Images in files are sent to vision models. While a run is going the message is queued and delivered between steps. |
| POST | /api/sessions/{id}/interrupt | Stop the current run. |
| POST | /api/sessions/{id}/fork | Copy the thread, optionally {before: messageID}. |
| DELETE | /api/sessions/{id} | Delete the thread. |
To-dos & goals
| GET | /api/sessions/{id}/todos | The agent's to-do list: [{content, status}] with status pending, in_progress or completed. |
| PUT | /api/sessions/{id}/todos | Replace the list: {todos: [...]}. The agent sees your edit on its next step. |
| GET | /api/sessions/{id}/goal | The goal, or null. |
| PUT | /api/sessions/{id}/goal | Set a goal: {text, criteria?, autopilot?: true, maxTurns?, start?: false}. With autopilot the agent keeps working until it reports the goal achieved or blocked. start: true sends the first message. |
| DELETE | /api/sessions/{id}/goal | Clear the goal. |
Changes & usage
| GET | /api/sessions/{id}/diff | Every file the thread changed, with a unified diff and added/removed line counts. |
| GET | /api/sessions/{id}/usage | Tokens in/out, cache reads, cost and context used, per model. |
| POST | /api/sessions/{id}/revert | Undo back to just before {messageId} — messages and files. Sending a new message makes it final. |
Approvals
In build mode the agent asks before risky actions. You get a permission.asked event, or poll:
| GET | /api/sessions/{id}/permissions | Waiting requests: [{id, action, resources, metadata}]. |
| POST | /api/sessions/{id}/permissions/{requestID} | Answer with {decision, message?}: once, always (this kind of action, for the rest of the thread) or reject (the message tells the agent why). |
When the agent asks you questions, the request's action is question and metadata.questions holds them. Answer with {decision: "answer", answers: [{selected: ["label", …], other: "free text"}, …]} — one entry per question — or {decision: "dismiss"} to let it decide on its own.
Live events
/api/eventsA Server-Sent Events stream of everything happening in every thread — the same feed the app draws from. Each event is one data: line of JSON:
data: {"id":"evt_…","type":"session.text.delta","created":1793270001234,
"data":{"sessionID":"ses_01J…","assistantMessageID":"msg_…","delta":"Adding the route"}}| Type | When |
|---|---|
session.execution.started / succeeded / failed / interrupted | A run begins and ends. |
session.text.delta | Streaming answer text (delta). |
session.reasoning.delta | Streaming thinking, for models that show it. |
session.tool.input.started | The model starts calling a tool (name: edit, shell, browser…). |
session.tool.called / success / failed | The call with its input, then its result. |
session.todos.updated | The to-do list changed. |
session.goal.updated | A goal was set, achieved or blocked. |
permission.asked / permission.replied | An approval is waiting / was answered. |
session.usage.updated | Tokens and cost after each model call. |
session.compaction.started / ended | Older messages are being summarised to free context (at 40, 60 or 80% — Settings → Agent). |
schedule.ran / schedule.failed | A scheduled job started a thread, or couldn't. |
A comment line (: ping) arrives every 15 seconds so idle connections stay open.
Scheduled jobs
Run a prompt in a project on a timetable — "every weekday at 9:00, update the dependencies and run the tests". Each run is a new thread you can read later. Jobs run while AFH is open; one that was due while it was closed runs once soon after it starts.
| GET | /api/schedules | All jobs, each with nextRun, lastRun, lastStatus and lastSession. |
| POST | /api/schedules | Create a job. |
| PUT | /api/schedules/{id} | Change a job (send the whole job). |
| POST | /api/schedules/{id}/run | Run it now → {sessionID}. |
| DELETE | /api/schedules/{id} | Delete it. |
| Field | |
|---|---|
name, directory, prompt | What to run, and where. The folder must exist. |
every | interval, hourly, daily, weekdays or weekly. |
minutes | For interval: 5 to 1440. |
time | "HH:MM" local time (for hourly only the minutes count). |
weekday | For weekly: 0 = Monday … 6 = Sunday. |
model, agent, mode | Like /v1/run. Mode defaults to full since nobody is there to approve. |
enabled | false pauses the job. |
afh.save_schedule({
"name": "Morning check",
"directory": r"C:\code\my-api",
"prompt": "Update outdated dependencies, run the tests, and fix anything that broke.",
"every": "weekdays",
"time": "09:00",
})Models
/api/models{models, defaultModel, providerNames}. Each model has providerID, id, name, context, input (e.g. ["text", "image"]) and its prices. Pass "providerID/id" as model anywhere above.
Python SDK
One file, standard library only, Python 3.9+. Download afh_sdk.py next to your script.
AFH(api_key=None, url=None, timeout=60) | Connect. Defaults: AFH_API_KEY, then AFH_URL or the discovery file. |
run(prompt, *, directory, session_id, model, agent, mode="full", wait=True, timeout=1800) | Do a task and wait for the answer. |
sessions(directory) · session(id) · messages(id) | Read threads. |
create_session(directory, *, title, model, agent, mode, worktree) | Start an empty thread. |
prompt(id, text, files=None) · reply(id) · interrupt(id) · delete_session(id) | Talk to a thread. |
todos(id) · set_todos(id, todos) · goal(id) · set_goal(id, text, criteria="", autopilot=True, start=False) | Plans and goals. |
diff(id) · usage(id) | What changed, what it cost. |
approvals(id) · approve(id, request_id, decision="once") | Answer approval requests. |
answer(id, request_id, answers) · skip_questions(id, request_id) | Answer the agent's question cards: one label, a list of labels, or {"selected": [...], "other": "..."} per question. |
providers() · add_provider(catalog, api_key, *, params) · test_provider(id) | List the 220+ providers AFH can use, connect one with a key, check it works. |
schedules() · save_schedule(job) · run_schedule(id) · delete_schedule(id) | Scheduled jobs. |
models() | Available models. |
events(session_id=None) | Generator of live events, optionally for one thread. |
From a terminal: python afh_sdk.py "Fix the failing test" C:\code\my-api
JavaScript SDK
One ES module for Node 18+ — no packages. Download afh.mjs.
await AFH.connect({ apiKey, url }) | Connect. Defaults like Python. |
run(prompt, { directory, sessionID, model, agent, mode, wait, timeout }) | Do a task and wait for the answer. |
sessions · session · messages · createSession · prompt · reply · interrupt · deleteSession | Threads. |
todos · setTodos · goal · setGoal · diff · usage | Plans, goals, changes. |
approvals · approve · answer · skipQuestions | Approvals and question cards. |
providers · addProvider · testProvider · models | Providers and models. |
schedules · saveSchedule · runSchedule · deleteSchedule | Scheduled jobs. |
for await (const ev of afh.events(sessionID)) | Live events. |
Recipes
Stream a run as it happens
started = afh.run("Refactor utils.py into a package", directory=repo, wait=False)
for ev in afh.events(started["sessionID"]):
if ev["type"] == "session.text.delta":
print(ev["data"]["delta"], end="", flush=True)
elif ev["type"] == "session.tool.input.started":
print(f"\n→ {ev['data']['name']}")
elif ev["type"].startswith("session.execution.") and ev["type"] != "session.execution.started":
breakReview every commit before it lands
#!/usr/bin/env node
import { AFH } from "./afh.mjs";
const afh = await AFH.connect();
const out = await afh.run("Review the changes on this branch against main. Reply PASS or list the problems.",
{ directory: process.cwd(), mode: "plan", timeout: 600 });
console.log(out.reply);
process.exit(out.reply.trim().startsWith("PASS") ? 0 : 1);Work toward a goal with autopilot
thread = afh.create_session(repo, title="Dark mode")
afh.set_goal(thread["id"], "Add a dark theme with a toggle in the header",
criteria="Toggle works, choice persists, tests pass", start=True)
while afh.reply(thread["id"])["running"]:
time.sleep(5)
print(afh.goal(thread["id"])["status"])