I have enough to draft now. The remaining details are implementation quirks rather than missing endpoint behavior, so I’m switching from repo exploration to writing the page.# Tasks API
The Tasks API lets you inspect Claude Code tasks across all sessions or within a single session. The key idea is simple: task IDs are not globally unique, so a full task lookup needs both the task id and the session_id it belongs to.
By default, the bundled server starts on 127.0.0.1:8080, so task endpoints are typically available under http://127.0.0.1:8080/tasks.
@app.command()
def serve(
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to (use 0.0.0.0 for network access)"),
port: int = typer.Option(8080, "--port", "-p", help="Port to bind"),
) -> None:
"""Start the API server."""
uvicorn.run(fastapi_app, host=host, port=port)
How Tasks Relate To Sessions
ccsinfo reads task JSON files from ~/.claude/tasks/<session-id>/. Session transcripts live separately under ~/.claude/projects/.../<session-id>.jsonl, but both use the same session UUID. That shared session_id is what ties the Sessions API and the Tasks API together.
def get_tasks_directory() -> Path:
"""Get the Claude Code tasks directory path.
Returns:
Path to ~/.claude/tasks/
"""
return Path.home() / ".claude" / "tasks"
Tip: If you know the session but not the task ID, start with
GET /sessions/{session_id}/tasks, then callGET /tasks/{task_id}?session_id=...for the single task you want.
Endpoint Summary
| Endpoint | What it does | Parameters |
|---|---|---|
GET /tasks |
List tasks across all sessions, or within one session | session_id optional, status optional |
GET /tasks/pending |
List all tasks whose status is pending |
None |
GET /tasks/{task_id} |
Get one task by ID within a specific session | session_id required |
GET /sessions/{session_id}/tasks |
List tasks for one session | session_id path parameter |
GET /tasks
Use GET /tasks to list tasks globally, or narrow the result set with query parameters.
Supported query parameters:
session_id: limit the list to one sessionstatus: limit the list to one status
Accepted status values are:
pendingin_progresscompleted
The route validates status and returns HTTP 400 for anything else.
@router.get("", response_model=list[Task])
async def list_tasks(
session_id: str | None = Query(None),
status: str | None = Query(None),
) -> list[Task]:
"""List all tasks."""
status_enum: TaskStatus | None = None
if status:
try:
status_enum = TaskStatus(status)
except ValueError as e:
raise HTTPException(
status_code=400,
detail=f"Invalid status: {status}. Valid values: pending, in_progress, completed",
) from e
return task_service.list_tasks(session_id=session_id, status=status_enum)
What to expect
- No query parameters: returns tasks from all sessions that have task files.
session_idonly: returns tasks from that session.statusonly: returns matching tasks across all sessions.session_idplusstatus: returns matching tasks inside that session only.- If nothing matches, you get an empty array.
Note: The API expects the exact lowercase status strings shown above.
pendingworks;PendingandPENDINGdo not.Note: Task endpoints do not expose pagination,
limit, or server-side sort parameters.
GET /tasks/pending
GET /tasks/pending is the convenience endpoint for “show me every pending task across all sessions.”
In the service layer, it is just a status filter:
def get_pending_tasks(self) -> list[Task]:
"""Get all pending tasks across all sessions.
Returns:
List of pending tasks.
"""
return self.list_tasks(status=TaskStatus.PENDING)
That means it is functionally equivalent to GET /tasks?status=pending.
Warning: “Pending” does not mean “ready to work.” Blocked tasks are still included here. If you need a ready queue, filter the results client-side so that
blocked_byorblockedByis empty.
The parser makes this distinction explicit:
def get_blocked_tasks(self) -> list[Task]:
"""Get all tasks that are blocked by other tasks."""
return [t for t in self.tasks if t.blocked_by]
def get_ready_tasks(self) -> list[Task]:
"""Get all pending tasks that are not blocked."""
return [t for t in self.tasks if t.status == "pending" and not t.blocked_by]
GET /sessions/{session_id}/tasks
This is the most practical session-scoped listing endpoint. Use it when you already know the session and want to see all of its tasks before drilling into one.
@router.get("/{session_id}/tasks")
async def get_session_tasks(session_id: str) -> list[dict[str, Any]]:
"""Get tasks for a session."""
tasks = task_service.get_session_tasks(session_id)
return [t.model_dump(mode="json") for t in tasks]
When to use it
- You already have a session ID from the Sessions API.
- You want to discover task IDs within that session.
- You want the full task list for one session without using query parameters.
Behavior details
- If the session has no task directory, this endpoint returns an empty array.
- It does not add additional filtering for status.
- It is the safest first step before calling the single-task detail endpoint.
GET /tasks/{task_id}
Use this endpoint when you need one specific task.
The important detail is that session_id is required as a query parameter:
@router.get("/{task_id}", response_model=Task)
async def get_task(
task_id: str,
session_id: str = Query(..., description="Session ID (required since task IDs are only unique within a session)"),
) -> Task:
"""Get task details."""
task = task_service.get_task(task_id, session_id=session_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
Why the extra session_id matters
Task IDs are only guaranteed to be unique inside a session. A task with ID 1 can exist in more than one session, so the API requires the session context to resolve the correct task.
Recommended lookup flow
- Get or identify the session ID.
- Call
GET /sessions/{session_id}/tasks. - Take the task
idyou want from that list. - Call
GET /tasks/{task_id}?session_id={session_id}.
Warning: If you omit the session context in your own tooling or UI flow, you can easily point to the wrong task or fail to resolve the task at all.
Task Shape
The task model exposes these core fields:
class TaskStatus(StrEnum):
"""Task status enum."""
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
class Task(BaseORJSONModel):
"""A Claude Code task from ~/.claude/tasks/<session-uuid>/*.json."""
id: str
subject: str
description: str = ""
status: TaskStatus = TaskStatus.PENDING
owner: str | None = None
blocked_by: list[str] = Field(default_factory=list, alias="blockedBy")
blocks: list[str] = Field(default_factory=list)
active_form: str | None = Field(default=None, alias="activeForm")
metadata: dict[str, Any] = Field(default_factory=dict)
created_at: datetime | None = None
Field reference
| Field | Meaning |
|---|---|
id |
Task identifier string |
subject |
Short task title |
description |
Longer description, defaulting to an empty string |
status |
One of pending, in_progress, or completed |
owner |
Optional owner/assignee |
blocked_by / blockedBy |
Task IDs that must finish first |
blocks |
Task IDs this task is blocking |
active_form / activeForm |
Optional active wording for the task |
metadata |
Free-form metadata object |
created_at |
Nullable timestamp field on the model |
Note: The repository uses snake_case fields in Python models and camelCase aliases for some raw task JSON fields. In practice,
blocked_bymaps toblockedBy, andactive_formmaps toactiveForm.
The test fixture below shows the raw task JSON shape the parser expects:
return {
"id": "1",
"subject": "Test task",
"description": "A test task",
"status": "pending",
"owner": None,
"blockedBy": [],
"blocks": [],
}
Ordering And Filtering Details
Within a session, tasks are read from *.json files and then sorted by task ID, using numeric ordering when the IDs are numeric strings.
def parse_session_tasks(session_id: str) -> TaskCollection:
"""Parse all tasks for a given session."""
tasks_dir = get_tasks_directory() / session_id
tasks: list[Task] = []
if not tasks_dir.exists():
logger.debug("No tasks directory found for session %s", session_id)
return TaskCollection(session_id=session_id, tasks=[])
for task_file in iter_json_files(tasks_dir, "*.json"):
task = parse_task_file(task_file)
if task is not None:
tasks.append(task)
# Sort by ID (numeric sort if possible)
tasks.sort(key=lambda t: (int(t.id) if t.id.isdigit() else float("inf"), t.id))
return TaskCollection(session_id=session_id, tasks=tasks)
What this means in practice:
- Per-session ordering is stable and ID-based.
- Cross-session listing is not exposed with a custom sort option.
- If a session has no task directory, session-scoped listing returns
[].
Note: If an individual task file fails to parse, the parser skips that file instead of failing the entire task list response.
Python Client Example From The Codebase
If you want to see how the project itself calls these endpoints, the built-in HTTP client is a good reference:
# Tasks
def list_tasks(
self,
session_id: str | None = None,
status: str | None = None,
) -> list[dict[str, Any]]:
params: dict[str, Any] = {}
if session_id:
params["session_id"] = session_id
if status:
params["status"] = status
return self._get_list("/tasks", params)
def get_task(self, task_id: str, session_id: str) -> dict[str, Any]:
return self._get_dict(f"/tasks/{task_id}", {"session_id": session_id})
def get_pending_tasks(self) -> list[dict[str, Any]]:
return self._get_list("/tasks/pending")
This mirrors the intended usage:
list_tasks()for broad listing and filteringget_pending_tasks()for a cross-session pending queueget_task(task_id, session_id)for an exact task lookup
Best Practices
- Use
GET /sessions/{session_id}/tasksfirst when you know the session but not the task ID. - Use
GET /tasks?status=in_progressorGET /tasks?status=completedfor simple status filtering. - Use
GET /tasks/pendingwhen you want a global backlog view. - Check
blocked_by/blockedBybefore treating a pending task as ready to work. - Do not assume task IDs are globally unique across sessions.