Added by the project workflow inspection PR (agentteams/AgentTeams#1169).
The controller exposes four read-only endpoints that surface TeamHarness
project state (shared/projects/{id}/meta.json) as a LangGraph-aligned
workflow view, plus per-team spawn subagent sessions and their conversation
streams. They are the data source for human-facing views (dashboard, QwenPaw
console plugin) and are consumed by agt get projects.
These endpoints work in any AgentTeams deployment that runs TeamHarness
(projectflow/taskflow) on its workers — the storage layout is read through
the controller's configured object-store client, so AGENTTEAMS_STORAGE_PREFIX
and AGENTTEAMS_FS_BUCKET (including non-default values) are handled
automatically; no per-deployment code or configuration is required.
Prerequisites:
- TeamHarness MCP (
plugins/teamharness) is installed on the workers that orchestrate projects. Only projects created byprojectflow(create_project/create_quick_project) produce theshared/projects/{id}/meta.jsonthat these endpoints read. Teams that manage tasks manually without projectflow have no project data — that is expected, not a bug. - Project writes are pushed to shared storage by
_sync_project(introduced alongside this API), so the controller sees near-live state rather than a startup snapshot.
Deployment modes (embedded Docker, incluster K8s) are all supported; in the no-K8s development mode the controller skips authentication like the other endpoints, so RBAC applies only when an authenticator is configured.
List projects across all teams (and the global shared/projects/ prefix).
Query parameters:
| Param | Meaning |
|---|---|
team |
Return only projects whose team matches. Team leaders are already scoped to their own team(s); standalone projects (empty team) only match when no filter is set. |
Response 200 OK:
{
"projects": [
{
"project_id": "demo-project-001",
"title": "Demo project",
"status": "active",
"plan_type": "dag",
"team_id": "biz-team",
"mode": "project"
}
],
"total": 1
}statusis the raw project status written by TeamHarness:active|paused|completed.- Projects are sorted by
project_id. Duplicate ids across prefixes are de-duplicated (meta.json may be mirrored under both the effective team name and the CR name prefix). - Projects with a missing or malformed
meta.jsonare skipped (the directory may exist while the file is mid-write upstream).
Return the LangGraph-aligned workflow for one project.
Optional query parameters:
| Parameter | Type | Meaning |
|---|---|---|
includeTasks |
bool |
When true, also read each task's TaskMeta (shared/tasks/{id}/meta.json) and attach a tasks_detail array with spec/result/deliverable fields, the opaque submission_id fence, and the transition history audit trail (omitted for metas that predate it). Default false keeps the response lightweight. |
format |
string |
Response format. Default (absent or empty) returns the JSON snapshot above. format=mermaid returns the same snapshot rendered as a Mermaid flowchart (text/plain, no tasks_detail — rendering needs only nodes/edges/next). Any other value returns 400. |
Mermaid output (?format=mermaid) mirrors LangGraph's draw_mermaid helper: each node label is name: status, next/ready nodes get the ready highlight class, and every other node gets a status class (pending / delegated / inProgress / completed / revision / blocked). All classDefs are emitted so the graph renders standalone. Task titles and ids are user-controlled, so they are sanitized for mermaid safety: newlines become <br>, double quotes become #quot;, backslashes are dropped, and other control characters become spaces; a task id containing characters outside [A-Za-z0-9_-] is mapped to a collision-safe node id (labels keep the original text). A malformed title therefore can never alter the rendered graph structure. Example:
flowchart LR
t1["Task 1: completed"]:::completed
t2["Task 2: delegated"]:::ready
t1 --> t2
classDef ready fill:#d4edda,stroke:#28a745;
...
Each tasks_detail[] entry carries history: [] — the task's auditable
state transitions recorded by the TeamHarness transition engine, oldest
first. Entry shape:
{
"ts": "2026-09-09T10:00:05Z",
"from": "assigned",
"to": "in_progress",
"action": "ack_task",
"actor": "worker:default",
"note": ""
}action is one of delegate_task, ack_task, submit_task,
accept_task_result, cancel_task, progress; actor is role:account
(MCP transitions) or the authorization actor (controller cancellations).
The allowed transitions are defined by the shared contract
plugins/teamharness/contracts/task-transitions.json.
Response 200 OK:
{
"project_id": "demo-project-001",
"title": "Demo project",
"status": "active",
"plan_type": "dag",
"team_id": "biz-team",
"mode": "project",
"source": "dingtalk",
"nodes": [
{"id": "t1", "name": "Task 1", "status": "completed", "assignee": "@w1:matrix.local"},
{"id": "t2", "name": "Task 2", "status": "delegated", "assignee": "@w2:matrix.local"}
],
"edges": [
{"source": "t1", "target": "t2", "conditional": false}
],
"next": ["t2"],
"interrupts": [
{"id": "t3", "value": "blocked"},
{"id": "loop", "value": "waiting for human decision"}
],
"values": {
"project_id": "demo-project-001",
"title": "Demo project",
"status": "active",
"plan_type": "dag",
"team_id": "biz-team",
"mode": "project",
"task_count": {"completed": 1, "delegated": 1}
},
"loop": null,
"requester": "dingtalk:user:session",
"source_room_id": "!room:matrix.local",
"tasks_detail": [
{
"task_id": "t1",
"project_id": "demo-project-001",
"status": "completed",
"spec_path": "shared/tasks/t1/spec.md",
"assigned_to": "@w1:matrix.local",
"summary": "Alpha report done",
"result_status": "SUCCESS",
"submission_id": "submission-123",
"deliverables": [{"type": "file", "path": "shared/tasks/t1/output.pdf"}],
"result_path": "shared/tasks/t1/result.md"
}
]
}tasks_detail is only present when ?includeTasks=true. It surfaces the
TaskMeta fields that the project-level nodes[] summary does not carry:
spec_path (task spec file), summary / result_status / result_path
(submission result), deliverables (artifact list), cancel_reason, and the
opaque submission_id used to fence accept/cancel decisions.
TaskMeta is read from the project's owning scope only: team projects read
teams/{team}/shared/tasks/{id}/meta.json, standalone projects read
shared/tasks/{id}/meta.json. There is no cross-scope fallback, and a
TaskMeta whose project_id names a different project is rejected — an
unrelated task that happens to share the id can never mix in. Tasks without
a TaskMeta file (e.g. not yet delegated) are skipped; per-task read errors
are skipped so one bad task never fails the whole response.
Node statuses are normalized to a frontend-friendly enum:
| API value | Raw TeamHarness status |
|---|---|
pending |
planned |
delegated |
assigned |
in-progress |
in_progress, submitted |
completed |
completed |
revision |
revision |
blocked |
blocked, cancelled |
Semantics (mirror upstream _ready_nodes / _ready_loop_nodes):
next— ready nodes: tasks whose raw status isplanned/assignedand whose dependencies are allcompleted. Empty when the project is not active or a loop iswaiting_user/blocked/completed.interrupts— human-decision waiting points: a blocked task, or a loop inwaiting_user/blockedstate.values.task_count— node counts per normalized status.
Error responses:
| Code | Meaning |
|---|---|
400 |
Missing project id. |
403 |
Authenticated but the role cannot read projects at all (e.g. Worker). |
404 |
Project not found (no meta.json under any scanned prefix) — or the caller is a scoped reader (team leader / L2 human) who does not own the project (existence is hidden to prevent id enumeration). |
500 |
K8s or object-store failure. |
Node-level inspection for one task: aggregates the task's graph node (status, assignee, dependencies), its TaskMeta (spec/summary/result/deliverables), the append-only transition history, and a trace hint for deep-linking into a tracing backend.
GET /api/v1/projects/{id}/tasks/{taskId}?team=alpha-team
Response 200 OK:
{
"task_id": "t1",
"project_id": "demo-project-001",
"status": "in-progress",
"spec_path": "shared/tasks/t1/spec.md",
"assigned_to": "@w1:matrix.local",
"summary": "Alpha report done",
"result_status": "SUCCESS",
"result_path": "shared/tasks/t1/result.md",
"deliverables": [{"type": "file", "path": "shared/tasks/t1/output.pdf"}],
"history": [
{"ts": "2026-09-05T01:00:00Z", "from": "", "to": "planned", "actor": "manager", "action": "create"},
{"ts": "2026-09-05T02:00:00Z", "from": "planned", "to": "in_progress", "actor": "w1", "action": "ack_task"},
{"ts": "2026-09-05T03:00:00Z", "from": "in_progress", "to": "submitted", "actor": "w1", "action": "submit_task"}
],
"dependencies": [],
"trace": {"project_id": "demo-project-001", "task_id": "t1"}
}Field notes:
submission_id, when present in TaskMeta, is returned verbatim. Pass it assubmissionIdwhen cancelling that submission.statusis the raw TaskMeta status when TaskMeta exists (same semantics astasks_detailwith?includeTasks=true); when TaskMeta is absent it falls back to the normalized graph-node status (pending | delegated | in-progress | completed | revision | blocked).historyis populated by TeamHarness taskflow (and the controller's cancel path) as an append-only audit of accepted transitions, capped at 50 entries; it is empty until the workflow transition engine lands (design: agentscope-ai#1223). Malformed entries are skipped, never an error.traceis a tracing-backend filter hint: itsproject_id/task_idare the values to match against the span attributesagentteams.project.id/agentteams.task.id, which worker entry spans already carry. No backend URL is constructed here; the tracing backend is deployment-specific.- TaskMeta is read from the project's owning scope only (team prefix first,
global prefix only for standalone projects) — the same no-cross-scope
fallback rule as
tasks_detail.
Errors: 400 (missing/invalid task id), 404 (project not found —
existence-hidden for scoped callers — or task not in this project's graph),
500 (storage read failure).
Download one of a task's artifacts, completing the "deliverable → download → review → accept" loop for dashboards and the console plugin.
Optional query parameter:
| Param | Meaning |
|---|---|
path |
The artifact path to download. Must be one of the task's declared artifacts — result_path, spec_path or an entry of deliverables (all read from TaskMeta). When omitted, the result_path (the published result) is served. |
Without ?path= the artifact is the task's result_path (published result).
With ?path= the requested path must be one of the task's declared artifacts
— result_path, spec_path (task spec) or a deliverables entry. The path
is then validated against a strict allowlist: it must be under
shared/tasks/{taskId}/ or shared/projects/{projectId}/, and must not
contain .. or start with /. Because the allowlist AND the declared-artifact
check both apply, a compromised worker cannot craft a path that reads
arbitrary MinIO objects, nor can a client download an undeclared file that
happens to live in the task directory.
The file is returned with Content-Disposition: attachment (filename =
basename, RFC 5987 filename*=utf-8''... for non-ASCII names so Chinese
filenames download correctly) and a Content-Type inferred from the
extension.
Error responses:
| Code | Meaning |
|---|---|
400 |
Missing project id or task id. |
403 |
Authenticated but the role cannot read projects at all (e.g. Worker). |
404 |
Project not found / caller does not own it (existence hidden) / task not in the project graph / task has no published artifact / requested path is not a declared artifact / artifact file missing / artifact path rejected / TaskMeta exists only outside the project's scope or belongs to another project. |
500 |
K8s or object-store failure. |
Aggregate the spawn subagent sessions created by the project team's workers. Spawn subagents are the primary execution vehicle of worker agents (workers schedule, spawns execute); this endpoint makes that activity visible alongside the team-level task graph.
Data source: each team worker's chats.json
(agents/{worker}/.qwenpaw/workspaces/default/chats.json in object storage,
mirrored by the worker's FileSync). A chat entry is a spawn session when
meta.spawn == true (QwenPaw 2.1+) or its session_id carries the sub-
prefix (2.0.1 fallback).
Response:
{
"project_id": "demo-project-001",
"workers": [
{
"worker": "sysdev-lead",
"spawns": [
{
"session_id": "sub-3f2a9b1c",
"name": "fix harmony build script",
"status": "running",
"created_at": "2026-08-13T10:00:00+00:00",
"updated_at": "2026-08-13T10:05:00+00:00",
"root_session_id": "matrix:!room:server",
"spawn": true,
"subagent_allowed_tools": ["read_file", "write_file"],
"subagent_skills": ["pdf"]
}
]
}
]
}Notes:
root_session_idis the normalized session key of the parent session (matrix:prefix canonicalized) — the session that calledspawn_subagent. The endpoint is project-scoped, not team-scoped: a spawn is listed only when its root session is one of the project's rooms (the projectsource_room_idor a graph task'sTaskMeta.room_id). A spawn rooted elsewhere — another project's room, or a legacy 2.0.1 spawn without a persisted root — is omitted, never attached to every project of the team.- A worker with a missing or unreadable
chats.jsonis still listed, with an emptyspawnsarray — one broken worker never 500s the whole project. subagent_allowed_tools/subagent_skillsare only present when the spawn was created with a tool/skill whitelist (2.1+).nameis the chat title assigned when the spawn session was created (not the full task prompt).- Standalone projects (no owning team) return an empty
workersarray — there is no team membership to derive the worker list from.
Error responses:
| Code | Meaning |
|---|---|
400 |
Missing project id. |
403 |
Authenticated but the role cannot read projects at all (e.g. Worker). |
404 |
Project not found / caller does not own it (existence hidden). |
500 |
K8s or object-store failure. |
Returns one spawn session's conversation stream — user context messages,
model turns and tool results — so a client can show what the spawn is doing,
its full task prompt, and its progress. The stream is read from the owning
worker's history.db (QwenPaw's scroll HistoryStore, mirrored into object
storage by the worker FileSync alongside chats.json).
Query parameters:
| Param | Type | Default | Meaning |
|---|---|---|---|
limit |
int | 20 |
Number of messages (most recent window). Capped at 50. |
Response:
{
"session_id": "sub-3f2a9b1c",
"task": "Design the auth module",
"messages": [
{
"seq": 1,
"kind": "context_msg",
"role": "user",
"content": "Design the auth module",
"created_at": "2026-08-13T15:00:00"
},
{
"seq": 2,
"kind": "model_turn",
"role": "assistant",
"name": "read_file",
"content": "Reading the spec first",
"headline": "read spec",
"created_at": "2026-08-13T15:00:05"
},
{
"seq": 3,
"kind": "tool_result",
"role": "assistant",
"name": "read_file",
"content": "spec.md contents",
"tool_state": "success",
"created_at": "2026-08-13T15:00:06"
}
],
"has_more": false
}kindis one ofcontext_msg(user message),model_turn(assistant reply —headlineis the turn's retrieval headline, "what it is doing at a glance"), ortool_result(tool execution result —nameis the tool andtool_stateits final state).taskis the first user message of the session — the spawn task text.messagesis ascending (oldest first), covering the most recentlimitrows;has_morereports whether older rows exist.- The database is pulled to a temp dir and opened strictly read-only
(pure-Go
modernc.org/sqlite), with a fallback that reads the checkpointed portion when the WAL sidecars are missing or inconsistent. The schema is stable across QwenPaw 2.0.1 and 2.1. - An empty session (db present, no rows) returns
200withmessages: [].
Error responses:
| Code | Meaning |
|---|---|
400 |
Missing project id or session id. |
403 |
Authenticated but the role cannot read projects at all (e.g. Worker). |
404 |
Project not found / caller does not own it (existence hidden) / session not owned by any team worker / history.db missing or unreadable. |
500 |
K8s or object-store failure. |
Returns the project's intervention timeline — the pre-intervention
meta.json snapshots written by the lifecycle endpoints (see
POST /pause, POST /resume, … below; each intervention snapshots the
previous state into history/{unixNano}.json, retaining the most recent 50).
Query parameters:
| Param | Type | Default | Meaning |
|---|---|---|---|
team |
string | — | Optional team qualifier, same semantics as the other read endpoints. |
Response:
{
"project_id": "demo-project-001",
"snapshots": [
{ "timestamp": "1723785123456789012" }
]
}snapshotsis newest first.timestampis the snapshot filename (unix nanoseconds) and is kept as a string — 19-digit nanosecond values exceed the JavaScript safe-integer range, so numeric transport would silently lose precision.- An empty history returns
200with"snapshots": [].
Returns one snapshot's raw meta.json content verbatim (same schema as
GET /workflow's source). timestamp must be a 19-digit unixNano value;
anything else is rejected 400 (this doubles as the traversal guard).
Error responses:
| Code | Meaning |
|---|---|
400 |
Missing project id / malformed timestamp. |
403 |
Authenticated but the role cannot read projects at all (e.g. Worker). |
404 |
Project or snapshot not found / caller does not own it (existence hidden). |
409 |
Ambiguous project id across teams; retry with ?team=. |
500 |
K8s or object-store failure. |
Returns the project's task transition timeline — a read-time
aggregation of every task's history array (the same audit trail
?includeTasks=true exposes per task), merged into one ascending list.
No new storage: the endpoint reads the task metas on demand and does not
add a write-side hook. Project-level intervention events are not part
of this timeline — use GET /history for those; the two are
complementary.
Query parameters:
| Param | Type | Default | Meaning |
|---|---|---|---|
team |
string | — | Optional team qualifier, same semantics as the other read endpoints. |
limit |
int | 50 |
Page size. Capped at 200; values < 1 are rejected 400. |
cursor |
string | — | Opaque cursor from a previous page's next_cursor; pass it back to continue. Encodes the page's last event identity (ts, task_id, seq), so it stays valid while new events are appended — and even while the per-task 50-entry history cap drops already-read events. For seq-less legacy events that share one second (the same identity), it additionally pins the event's position within that duplicate group, so paging always advances. |
Response:
{
"project_id": "demo-project-001",
"events": [
{
"ts": "2026-09-09T10:00:00Z",
"task_id": "t1",
"from": "planned",
"to": "prepared",
"action": "delegate_task",
"actor": "leader:default",
"seq": 1
},
{
"ts": "2026-09-09T10:00:05Z",
"task_id": "t1",
"from": "assigned",
"to": "in_progress",
"action": "ack_task",
"actor": "worker:default",
"note": "starting",
"seq": 2
}
],
"next_cursor": "eyJ0cyI6IjIwMjYt..."
}eventsis oldest first; the shared second-resolution timestamps are tie-broken bytask_id, and events sharing both keep the writer's append order (seq), so paging is deterministic.- Every event carries
seq, the writer-persisted per-task sequence number: the stable event identity. Timestamps are second-resolution and repeated progress entries are allowed, so content alone cannot identify an event. next_cursoris empty when the tail was reached; an empty project returns200with"events": [].next_cursoris an opaque, URL-safe string. The client never parses it; it anchors on the page's last event by exact identity (ts, task_id, seq), so appending new events between page requests does not invalidate it and duplicates (same second, same content) are never skipped or repeated. A cursor anchored inside a group of seq-less legacy duplicates (same second, noseq) carries the event's occurrence index within the group plus a snapshot of the list length, so paging such groups — including completed read-only histories that will never receive aseqbackfill — advances exactly one event per page and can never stall.cursor_expiredistrue(with"events": []and nonext_cursor) when the cursor's anchor event has been truncated out of the retained per-task history (50 entries, oldest dropped), the snapshot behind a legacy duplicate cursor was truncated (its position in the duplicate group may have shifted), or the cursor predates the sequence format. On that signal the client must discard the cursor and re-fetch from the start; continuing would otherwise skip unread events silently.- Task metas are read from the project's owning scope only — no
cross-scope fallback (same rule as
tasks_detail).
Error responses:
| Code | Meaning |
|---|---|
400 |
Missing project id / invalid limit or cursor. |
403 |
Authenticated but the role cannot read projects at all (e.g. Worker). |
404 |
Project not found / caller does not own it (existence hidden). |
409 |
Ambiguous project id across teams; retry with ?team=. |
500 |
K8s or object-store failure. |
Project ids are only unique within a worker workspace upstream: two teams can
hold the same project id. The API therefore treats (team, project_id) as
the identity:
GET /api/v1/projectslists every distinct(team, project_id)— the same id under two teams appears twice, disambiguated byteam_id(a scoped caller only sees the entries of their accessible teams).- The read endpoints (
workflow,tasks/{taskId}/artifact,spawns,spawns/{sessionId}/messages,history,history/{timestamp}) accept an optional?team=query parameter that narrows resolution to that team's storage prefix. - Without
?team=, if the same project id exists under multiple teams the endpoint returns409 Conflict(project id is ambiguous across teams; retry with ?team=) instead of silently resolving to the first match. Callers scoped to one of those teams (team leader / L2 human) resolve their own team's project without ambiguity.
Two bearer-token paths are accepted (composite authenticator):
- Kubernetes service-account token (TokenReview): admin / manager /
worker. Team leaders (worker with
team_leaderrole) see only their own team's projects. - Matrix access token (L2 humans): the token is validated with
GET /_matrix/client/v3/account/whoami; the owning Matrix localpart is matched to aHumanCR withpermissionLevel: 2(Team). The human'saccessibleTeamsset is used as the multi-team scope — every team they control is aggregated into a single list/read view. Non-L2 humans (permissionLevel 1 or 3) are rejected.
Authorization matrix:
| Caller | List | Get workflow | Get spawns | Get spawn messages | Get history |
|---|---|---|---|---|---|
| admin / manager | all teams | any project | any project | any project | any project |
| team-leader (SA) | own team only | own team only | own team only | own team only | own team only |
| L2 human (Matrix) | all accessibleTeams |
any accessible team | any accessible team | any accessible team | any accessible team |
| worker | denied | denied | denied | denied | denied |
agt get projects [name] wraps both endpoints:
agt get projects # list all
agt get projects --team biz-team # filter by team
agt get projects demo-project-001 # workflow detail
agt get projects demo-project-001 -o json
agt get projects demo-project-001 --mermaid # render DAG as mermaid (status classes)--mermaid uses the same renderer as the API's ?format=mermaid: next/ready
nodes are highlighted and every node is colored by status (see the workflow
endpoint section above).
Node-level inspection has no dedicated CLI verb yet; use the API directly:
curl -H "Authorization: Bearer $AGENTTEAMS_AUTH_TOKEN" \
"$AGENTTEAMS_API_BASE/api/v1/projects/demo-project-001/tasks/t1"The CLI forwards whatever bearer token is configured (AGENTTEAMS_AUTH_TOKEN
or AGENTTEAMS_AUTH_TOKEN_FILE) verbatim, so an L2 human can also use it by
pointing either variable at their own Matrix access token — no separate CLI
auth mode is needed.
The read endpoints above are complemented by write endpoints that let
humans intervene in agent-orchestrated workflows. All writes are code-level
authorized: the middleware rejects cross-team writes (authorizer
requireSameTeam), and the handler additionally runs checkProjectAccess
after resolving the owning team because the middleware cannot map a project
path to a team. Every write is stamped with audit fields
(updated_by / updated_at, and pause_reason when a reason is given) and
applies an ETag conditional write — the object's ETag (content hash) is
bound at read time and the write is a MinIO If-Match conditional write, so
a worker pushing a newer meta.json between the read and the write makes
the write fail with 409 instead of clobbering it.
Create a project (structured, aligned with TeamHarness create_project).
An admin/manager may create a standalone project without a team; a team
leader or L2 human must pass a team_id they can access.
Request body:
{
"title": "New project",
"source": "matrix",
"requester": "@luo:server",
"team_id": "biz-team",
"project_id": "optional-custom-id",
"source_room_id": "!room:server"
}project_id defaults to a generated value when omitted and must be a plain
token matching TeamHarness _safe_id ([A-Za-z0-9][A-Za-z0-9._-]*); the
generated default is a compact timestamp + nanoseconds (never an RFC3339
timestamp — its : would be rejected by TeamHarness). Response 201 Created:
{
"project_id": "proj-20260814-160628-406426239",
"title": "New project",
"status": "active",
"team_id": "biz-team",
"plan_type": "dag"
}Errors: 400 missing/invalid title or project id / team required for
scoped callers; 409 project already exists; 403/404 cross-team
(denied / existence hidden).
Set a project's status to paused. Pausing stops new task dispatch
(ready_nodes returns empty) but does not interrupt in-flight tasks; their
completion reports still arrive (documented behavior — in-flight work is
not cancelled). Optional body {"reason": "..."} is recorded in
pause_reason. Response 200 returns the updated workflow
(buildWorkflow). Errors: 409 already paused / completed; 404 not
found or not owned.
Set a paused project back to active. Response 200 returns the updated
workflow. Errors: 409 not paused; 404 not found or not owned.
Replace a project's DAG plan. The request body carries the new tasks
(optional tasks array):
{
"tasks": [
{"taskId": "t1", "title": "Step 1", "assignedTo": "@dev:server", "dependsOn": []},
{"taskId": "t2", "title": "Step 2", "dependsOn": ["t1"]}
]
}Fields are normalized like TeamHarness _normalize_task
(taskId/task_id, assignedTo/assigned_to, dependsOn/depends_on,
status defaults to planned, pending maps to planned); a task id that
already exists keeps its previous title/assignee/status when the raw entry
omits them. Validation mirrors _validate_task_graph: duplicate ids,
unknown dependencies, and dependency cycles are rejected with 400.
Preconditions (409): plan_type must be dag (loop replans go through
record_loop_iteration), status must be active, and no task may be
in_progress/submitted. Response 200 returns the updated workflow.
Tasks with a persisted cancellation decision keep that decision when retained
in a replan. They cannot be reopened or removed and then re-added under the
same task id; create a new task id for replacement work.
Cancel a single task:
{
"reason": "no longer needed",
"replacementTaskId": "replacement-01",
"submissionId": "submission-123"
}reason is required and replacementTaskId is optional. submissionId is
conditionally required: when TaskMeta already has submission_id, callers
must send that exact opaque value. Missing, invented, or stale identities are
rejected before either ProjectMeta or TaskMeta is changed.
On success, the project node and TaskMeta become cancelled; TaskMeta records
stable cancel_reason / replacement_task_id / cancelled_at fields and
resolves an existing pending continuation as cancelled without changing its
delivery_id. Repeating the same cancellation is idempotent. A different
reason, replacement task, or submission identity conflicts with the committed
decision. Tasks already completed, revision, or blocked cannot be
cancelled. Response 200 returns the updated workflow.
The Controller writes a small cancellation decision envelope into the project node before writing TaskMeta. If the second write fails, an identical retry can finish the TaskMeta/continuation update; a retry with a different reason, replacement, or submission identity is rejected.
Errors: 400 missing reason or invalid replacement task id; 404 task not in project / task meta missing;
409 terminal task, submission fence failure, or conflicting cancellation.
Mark a project completed (terminal state). All tasks must be in a terminal
status (completed/revision/blocked/cancelled — no in_progress/submitted/
planned), otherwise 409. Response 200 returns the updated workflow.
After a successful write, the Controller sends an admin message
(SendMessageAsAdmin) to the project's source_room_id (falling back to
reply_route.target_session), so agents in that room learn about the
intervention without polling. Best-effort: no notification is sent when
the room is unknown or Matrix is not configured.
Two bearer-token paths are accepted (composite authenticator):
- Kubernetes service-account token (TokenReview): admin / manager /
worker. Team leaders (worker with
team_leaderrole) see only their own team's projects. - Matrix access token (L2 humans): the token is validated with
GET /_matrix/client/v3/account/whoami; the owning Matrix localpart is matched to aHumanCR withpermissionLevel: 2(Team). The human'saccessibleTeamsset is used as the multi-team scope — every team they control is aggregated into a single list/read view. Non-L2 humans (permissionLevel 1 or 3) are rejected.
Authorization matrix:
| Caller | List | Get workflow | Write (create/pause/resume/replan/cancel/complete) |
|---|---|---|---|
| admin / manager | all teams | any project | any project |
| team-leader (SA) | own team only | own team only | own team only |
| L2 human (Matrix) | all accessibleTeams |
any accessible team | any accessible team |
| worker | denied | denied | denied |
agt get projects [name] wraps both endpoints:
agt get projects # list all
agt get projects --team biz-team # filter by team
agt get projects demo-project-001 # workflow detail
agt get projects demo-project-001 -o json
agt get projects demo-project-001 --include-tasks -o json
agt get projects demo-project-001 --mermaid # render DAG as mermaid (status classes)--mermaid uses the same renderer as the API's ?format=mermaid: next/ready
nodes are highlighted and every node is colored by status (see the workflow
endpoint section above).
Node-level inspection has no dedicated CLI verb yet; use the API directly:
curl -H "Authorization: Bearer $AGENTTEAMS_AUTH_TOKEN" \
"$AGENTTEAMS_API_BASE/api/v1/projects/demo-project-001/tasks/t1"The CLI forwards whatever bearer token is configured (AGENTTEAMS_AUTH_TOKEN
or AGENTTEAMS_AUTH_TOKEN_FILE) verbatim, so an L2 human can also use it by
pointing either variable at their own Matrix access token — no separate CLI
auth mode is needed.
--include-tasks requires -o json; the default detail view does not render
raw TaskMeta fields.
agt project wraps the write endpoints so a human can intervene without
raw curl:
agt project create --title "New project" --team biz-team --source matrix
agt project pause demo-project-001 --reason "customer review"
agt project resume demo-project-001
agt project replan demo-project-001 --tasks tasks.json # JSON array file
agt project cancel demo-project-001 demo-project-001-01 \
--reason "no longer needed" --submission-id submission-123 --team biz-team
agt project complete demo-project-001The same bearer-token forwarding applies (Matrix token for L2 humans).
The Controller proxies two read-only endpoints of each worker's QwenPaw app
(checkpoint system, QwenPaw ≥ 2.1) so humans and frontends can inspect a
worker's execution timeline — auto snapshots after every response round plus
manual /checkpoint snapshot, stored in the worker's checkpoints/shadow.git.
| Endpoint | Meaning |
|---|---|
GET /api/v1/workers/{name}/checkpoints/graph |
Checkpoint graph (nodes with kind/timestamp/query preview, sessions, summary). Optional ?limit= (1..1000). |
GET /api/v1/workers/{name}/checkpoints/status |
auto_enabled, has_checkpoints, workspace_dir. |
- Scope: same worker read authorization as
GET /api/v1/workers/{name}— team leaders / L2 humans only see workers in their teams; unknown or out-of-scope workers are hidden as404. - Embedded mode only: the endpoints proxy the worker's qwenpaw app inside
the shared docker network. The upstream address is resolved from the
effective container prefix (
AGENTTEAMS_PROXY_CONTAINER_PREFIX, or derived fromAGENTTEAMS_RESOURCE_PREFIXwhen auto-prefixing is enabled; empty when disabled — the same value the docker backend uses for container naming) and the effective console port resolved through the same system-wins env chain used at container creation (the system env always definesAGENTTEAMS_CONSOLE_PORT, so a conflictingWorker.spec.envvalue is discarded and the container always listens on8088). In kube mode they return503. - Degradation: a worker running QwenPaw < 2.1 has no checkpoint router,
so the upstream
404is translated to502withcheckpoint API unavailable (requires QwenPaw 2.1). - Forwarding is fixed-path (graph / status) with a strict query whitelist — not a generic reverse proxy.
Error responses:
| Code | Meaning |
|---|---|
400 |
Invalid worker name / unsupported subpath or query parameter / invalid limit. |
404 |
Worker not found / caller does not own it (existence hidden). |
502 |
Worker app unreachable, pre-2.1 checkpoint API, or upstream error. |
503 |
Kube mode (no stable worker pod DNS to proxy). |
The Controller proxies four endpoints of each worker's QwenPaw app
(QwenPaw ≥ 2.1) so L2 humans and frontends can inspect — and, where the
caller's Human CR permits, update — a worker's knowledge base: the
long-term memory file MEMORY.md, the daily note tree memory/, and the
distilled knowledge tree digest/.
| Endpoint | Meaning |
|---|---|
GET /api/v1/workers/{name}/workspace-files/tree |
Paginated listing of one knowledge directory: ?path= (required, memory / digest or any subpath of them), optional ?cursor= (opaque) and ?limit= (1..500). Returns {directory, entries[], has_more, next_cursor}. |
GET /api/v1/workers/{name}/workspace-files/file-metadata |
?path= (required, an allowed knowledge file): {etag, modified_at, path, preview_kind, size}. |
GET /api/v1/workers/{name}/workspace-files/file-content |
?path= (required) plus optional ?offset= (≥0) and ?limit= (1..1048576): a bounded UTF-8 chunk {content, eof, next_offset, truncated, etag, ...} — continue with offset=next_offset while truncated is true. |
PUT /api/v1/workers/{name}/workspace-files/file-content |
Save one knowledge file: ?path= (required), body {"content": "<text>"} (1 MiB cap, non-empty) and the If-Match header (see the concurrency rule below). Returns the new {etag, path, size}. |
GET /api/v1/workers/{name}/workspace-files/file-download |
Stream one knowledge file as an attachment: ?path= (required). Forwards the upstream Content-Disposition / Content-Length / ETag headers. |
- Scope: same worker read authorization as
GET /api/v1/workers/{name}and the checkpoint endpoints — team leaders / L2 humans only see workers in their accessible teams; unknown or out-of-scope workers are hidden as404. - Write scope:
PUT file-contentis allowed for admin/manager (any team); an L2 human may write only workers in their own teams whileHuman.spec.workspaceFileAccessis explicitly"readwrite"— the default isread(an empty or unset value means read-only), so a controller upgrade cannot silently grant pre-existing humans write access; L1 grants a user write access by setting the field toreadwrite. Team leaders stay read-only on this API. Cross-team writes hide the worker as404(existence must not be probeable); an in-scope caller without write permission gets an explicit403. - Concurrency (ETag): before a write the proxy probes
file-metadata. For an existing file theIf-Matchheader is mandatory (a worker auto-appends to its memory files, so an unconditional overwrite would be a lost update) and for a new file it must be absent. An upstream ETag mismatch passes through as409— reload the file and retry. - Write limits: the body is capped at 1 MiB (the read chunk cap),
contentmust be a non-empty string, and every successful write is audit-logged by the controller (worker, path, caller, byte count). workspaceFileAccess(Human CRD field):read|readwrite(default isreadwhen empty — write is an explicit opt-in) — the per-user read/write permission L1 can set on a human's access to team knowledge base files. Settable at human creation (agt apply) and throughPUT /api/v1/humans/{name}(that update API carries the field in its updatable set, shipped with the humans-update PR).- Path allowlist (knowledge boundary): only
MEMORY.md,memory/**anddigest/**are addressable — for reads and writes alike. Every other workspace location —SOUL.md,PROFILE.md,TODO.md,checkpoints/,skills/, and all dot directories (.copaw/agent.jsoncarries the worker's credentials) — is rejected with400before the request reaches the worker. Roots match on the exact first segment, somemories/andmemoryX/are not prefixes ofmemory/. File roots are single top-level files:MEMORY.mdis addressable, butMEMORY.md/foois rejected (MEMORY.mdis one file, not a directory — nested files must live undermemory/ordigest/). - Root pinned: the QwenPaw
root=workspaceparameter (the agent's own storage root, as opposed toroot=project, the primary bound project directory) is fixed server-side and is not part of the client query surface. - Embedded mode only: the endpoints proxy the worker's qwenpaw app
inside the shared docker network, using the effective container prefix
and the system-wins console port — the same address resolution as the
checkpoint endpoints. In kube mode they return
503. - Version gate (passthrough 404): a worker running QwenPaw < 2.1 has no
workspace file router, so every request is an upstream
404passed through verbatim. To distinguish "worker too old" from "file missing", probefile-metadata?path=MEMORY.md— that file exists in every initialized QwenPaw workspace, so a404there means the worker is pre-2.1 (or its workspace is not initialized) while other404s are plain missing files. - Runtime scope: the endpoints target workers running the QwenPaw app
(
qwenpawruntime). Workers on other runtimes have no QwenPaw workspace API: when that runtime's app serves the console port the proxy passes its response through verbatim (typically404); when nothing listens it returns502. The MEMORY.md probe is therefore only meaningful for QwenPaw workers. - Forwarding is fixed-path (tree / file-metadata / file-content GET+PUT /
file-download) with a strict query whitelist — not a generic reverse
proxy. The multipart
file-uploadendpoint and the rest of the workspace API surface remain unreachable.
Each QwenPaw worker's agent profile carries a tool-execution security level
(approval_level in the worker's agent.json) that decides which tool calls
run automatically and which pause for a human approval. The Controller
proxies a minimal read/write surface of the worker's
/workspace/running-config API so L2 humans can manage this level for
workers in their own teams:
| Endpoint | Meaning |
|---|---|
GET /api/v1/workers/{name}/approval |
Current level: {"approval_level": "AUTO"}. |
PUT /api/v1/workers/{name}/approval |
Set the level. Body: {"approval_level": "STRICT"}. |
- Levels:
STRICT(every tool call needs approval) /SMART(low-risk tools auto-allowed) /AUTO(only guarded tools — the upstream default) /OFF(guard disabled). Any other value is rejected with400before the worker is touched (the upstream model does not validate the value — the proxy is the validation boundary). - OFF is elevated:
approval_level=OFFdisables Tool Guard entirely, so it is a security-policy operation rather than ordinary configuration. Default L2 humans get403onOFF— they may switch among the guarded levels (STRICT/SMART/AUTO) only.OFFrequires the elevated tool-approval capability that the L2 permission design (#1220) defines and admins grant explicitly; admin/manager keep the full range until that capability model lands. - Write scope:
PUTis allowed for admin/manager (any team); an L2 human may set only workers in their own teams — cross-team workers hide as404(existence is not probeable). Team leaders stay read-only (403onPUT, the same boundary as the knowledge base write API). - Safe write: the upstream
PUT /workspace/running-configpersists the full running-config object, so the proxy performs GET → change onlyapproval_level→ PUT the whole object back; every other field round trips verbatim. An upstream409(concurrent config change) is passed through so clients retry with a freshGET. - Embedded mode only: same worker addressing as the checkpoint proxy
(effective container prefix + system-wins console port). Kube mode returns
503. - Degradation: a worker on a QwenPaw version without the running-config
router surfaces the upstream
404verbatim (version gate). - Every successful change is audit-logged (worker, new level, caller, role).
Error responses:
| Code | Meaning |
|---|---|
400 |
Invalid worker name / unsupported subpath or query parameter / path outside the knowledge allowlist / out-of-bounds limit or offset / (write) missing or misplaced If-Match, over-size or empty body. |
403 |
(write only) in-scope caller without write permission — read-only human or team leader. |
404 |
Worker not found or not in the caller's teams (existence hidden); or (passthrough) the file does not exist — see the version-gate probe above. |
409 / 416 |
(passthrough) the file changed while being read / while waiting for the write (ETag mismatch — reload and retry) / offset beyond end of file. |
502 |
Worker app unreachable, or an upstream error (status echoed in the body). |
| 400 | Invalid worker name / invalid request body / approval_level not in the fixed set. |
| 403 | Team leader attempting PUT (read-only) / L2 human setting OFF (elevated capability required, #1220). |
| 404 | Worker not found / caller does not own it (existence hidden) / pre-2.x worker (version gate, passthrough). |
| 409 | Concurrent upstream config change (retry with a fresh GET). |
| 502 | Worker app unreachable or upstream error. |
| 503 | Kube mode (no stable worker pod DNS to proxy). |
Each QwenPaw worker's agent profile carries a per-tool table
(tools.builtin_tools): which built-in tools are enabled and which execute
asynchronously. The Controller proxies a minimal read/write surface of the
worker's /api/tools API so L2 humans can manage the tools of workers in
their own teams — no docker exec required.
| Endpoint | Meaning |
|---|---|
GET /api/v1/workers/{name}/tools |
Tool list: {"tools": [ {name, enabled, description, asyncExecution, icon, requiresConfig}, ... ], "total": N}. |
PATCH /api/v1/workers/{name}/tools/{tool} |
Declarative update. Body: one or both of {"enabled": bool, "asyncExecution": bool}. |
- Declarative, retry-safe: the proxy reads the current table first and
issues the worker-local mutation only for each field whose requested value
differs from the current one (the worker-local toggle endpoint flips state
without a body, so a bare forward would double-flip on a retry). A PATCH
that changes nothing is a
200no-op with zero upstream writes. - Concurrent-safe: because the worker-local enabled mutation is a blind
toggle, the read-decide-mutate sequence runs under a per-(worker, tool)
lock and the mutation response is verified to carry the requested
enabledvalue. Two overlappingPATCH {"enabled":true}requests both return200and leave the tool enabled (the second observes the first's write and no-ops); a verification mismatch is a502, never a false200. - State only, never configuration: the entry exposes
requiresConfig(a flag) but never the tool's configuration values — they can hold credentials. - Write scope:
PATCHis allowed for admin/manager (any worker); an L2 human may patch only workers in their own teams — cross-team workers hide as404(existence is not probeable). Team leaders stay read-only (403onPATCH, the same boundary as the approval endpoint). - Unknown field names are rejected with
400(fail-closed); the controller-facing names are camelCase (asyncExecution,requiresConfig). - Runtime-aware: the tool-settings model is QwenPaw-specific; workers on
another runtime surface
400. Embedded mode only (kube mode503). - Failure semantics: unknown worker/tool
404; upstream error502(a QwenPaw build without the/api/toolsrouter is a502"API unavailable"); a malformed upstream list fails closed with502rather than a partial list. - Live effect: the worker-local mutation saves the agent profile and hot-reloads the agent; the worker's sync loop persists the profile to the shared store.
- Every successful change is audit-logged (worker, tool, changed fields with old/new values, caller, role).