API v1 · AFH 1.8.7

Build on AFH.

Everything the app does is a local HTTP API. Start an agent from a script, a git hook or CI, stream what it does live, and schedule work for later — with one key and a few lines of Python or JavaScript.

Quick start Python SDK JavaScript SDK

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.

Local onlyThe API listens on your computer. Nothing is exposed to the network unless you turn on Remote control.
One keyCreate API keys in Settings → Developer. Keys are stored hashed and can be revoked at any time.
No dependenciesEach SDK is a single file: Python's standard library, or Node 18+ fetch.

The app also serves an interactive reference generated from your exact version: Settings → Developer → Interactive reference (it opens /api/docs).

Quick start

  1. Create a key. In AFH open Settings → Developer and click Create key. Copy it — it's shown only once.
  2. Save it as an environment variable so it stays out of your code:
    setx AFH_API_KEY "afh_your_key_here"
  3. 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"])

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:

StatusMeaning
400A field is missing or wrong — the message says which.
401No key, or the key was deleted.
403The request came through a host name other than 127.0.0.1/localhost.
404No thread, job or key with that id.
503The 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

POST/api/v1/run

The 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.

FieldType
promptstringRequired. What you want done.
directorystringThe project folder for a new thread. Required unless you pass sessionID.
sessionIDstringContinue this thread instead of starting one.
modelstring"provider/model", e.g. "anthropic/claude-sonnet-5-5". Default: the app's default model.
agentstringAn agent id from Settings → Prompts & agents.
modestringfull (default — no approvals, it just works), build (asks before risky actions) or plan (read-only planning).
waitbooleanDefault true. false returns at once with the thread id.
timeoutnumberSeconds 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"
}
GET/api/v1/sessions/{id}/reply

For 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/sessionsCreate: {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}/messagesEvery message with its parts: text, reasoning, tool calls and their results.
POST/api/sessions/{id}/promptSend {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}/interruptStop the current run.
POST/api/sessions/{id}/forkCopy the thread, optionally {before: messageID}.
DELETE/api/sessions/{id}Delete the thread.

To-dos & goals

GET/api/sessions/{id}/todosThe agent's to-do list: [{content, status}] with status pending, in_progress or completed.
PUT/api/sessions/{id}/todosReplace the list: {todos: [...]}. The agent sees your edit on its next step.
GET/api/sessions/{id}/goalThe goal, or null.
PUT/api/sessions/{id}/goalSet 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}/goalClear the goal.

Changes & usage

GET/api/sessions/{id}/diffEvery file the thread changed, with a unified diff and added/removed line counts.
GET/api/sessions/{id}/usageTokens in/out, cache reads, cost and context used, per model.
POST/api/sessions/{id}/revertUndo 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}/permissionsWaiting 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

GET/api/events

A 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"}}
TypeWhen
session.execution.started / succeeded / failed / interruptedA run begins and ends.
session.text.deltaStreaming answer text (delta).
session.reasoning.deltaStreaming thinking, for models that show it.
session.tool.input.startedThe model starts calling a tool (name: edit, shell, browser…).
session.tool.called / success / failedThe call with its input, then its result.
session.todos.updatedThe to-do list changed.
session.goal.updatedA goal was set, achieved or blocked.
permission.asked / permission.repliedAn approval is waiting / was answered.
session.usage.updatedTokens and cost after each model call.
session.compaction.started / endedOlder messages are being summarised to free context (at 40, 60 or 80% — Settings → Agent).
schedule.ran / schedule.failedA 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/schedulesAll jobs, each with nextRun, lastRun, lastStatus and lastSession.
POST/api/schedulesCreate a job.
PUT/api/schedules/{id}Change a job (send the whole job).
POST/api/schedules/{id}/runRun it now → {sessionID}.
DELETE/api/schedules/{id}Delete it.
Field
name, directory, promptWhat to run, and where. The folder must exist.
everyinterval, hourly, daily, weekdays or weekly.
minutesFor interval: 5 to 1440.
time"HH:MM" local time (for hourly only the minutes count).
weekdayFor weekly: 0 = Monday … 6 = Sunday.
model, agent, modeLike /v1/run. Mode defaults to full since nobody is there to approve.
enabledfalse 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

GET/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 · deleteSessionThreads.
todos · setTodos · goal · setGoal · diff · usagePlans, goals, changes.
approvals · approve · answer · skipQuestionsApprovals and question cards.
providers · addProvider · testProvider · modelsProviders and models.
schedules · saveSchedule · runSchedule · deleteScheduleScheduled 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":
        break

Review 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"])