The Harbormaster approval queue is the human-in-the-loop gate for agent actions that match a policy rule. Enqueue is internal-only — it fires from harbormaster.Gate when an agent hits a rule-matching action — so the public surface is the read + decide flow operators use to triage, approve, or deny pending requests. See the Harbormaster guide.
All endpoints require authentication and are workspace-scoped. Decision endpoints additionally require OWNER or ADMIN role.
Endpoints
| Method | Endpoint | Purpose |
|---|
| GET | /api/v1/approvals | List approval requests |
| GET | /api/v1/approvals/{id} | Get a single request |
| POST | /api/v1/approvals/{id}/decide | Approve or deny a request |
| POST | /api/v1/approvals/reset-auto-tuning | Reset learned auto-tuning state for a tool |
Queue
Read the approval queue and inspect individual requests.
List approvals
GET /api/v1/approvals?status=pending&limit=50
Query parameters:
| Param | Type | Default | Description |
|---|
status | string | pending | One of pending, approved, denied, timeout, cancelled, all. all removes the status filter. |
limit | integer | 50 | 1-200. |
Response: 200 OK
{
"rows": [
{
"ID": "req_01HXYZABCDEF",
"WorkspaceID": "ws_123",
"CrewID": "crw_backend",
"AgentID": "agt_viktor",
"MissionID": "MIS-42",
"RequestedBy": "agt_viktor",
"Kind": "destructive_op",
"Reason": "rm -rf /data/*",
"Payload": { "tool": "Bash", "args": { "command": "rm -rf /data/*" } },
"Status": "pending",
"DecidedBy": "",
"DecidedAt": null,
"DecisionComment": "",
"TimeoutAt": "2026-04-17T11:23:41Z",
"CreatedAt": "2026-04-17T10:23:41Z",
"TimeoutSecs": 0
}
],
"status": "pending",
"count": 1
}
The rows[] objects are serialized with their Go field names (PascalCase) — the harbormaster.Request struct carries no JSON tags. The rows, status, and count envelope keys are lowercase. TimeoutSecs is an in-memory-only field that is never read back from the row, so it always serializes as 0 here (the effective deadline is TimeoutAt). DecidedAt and TimeoutAt are pointers — they serialize as null when unset rather than being omitted.
For ?status=all, the status envelope key echoes back as an empty string (""), not "all" — the handler maps all to “no status filter” internally.
| Field | Type | Description |
|---|
rows[].Kind | string | tool_call, cost_threshold, destructive_op, target_environment, custom. |
rows[].Status | string | See Status values above. |
rows[].Payload | object | Tool call context (tool, args, plus any rule-specific fields). |
rows[].TimeoutAt | string? | RFC3339 deadline; the sweeper flips past-due rows to timeout. |
Get approval
GET /api/v1/approvals/{id}
Returns the full request object (same schema as list rows).
Errors:
| Status | Condition |
|---|
| 401 | No workspace. |
| 404 | ID not found or belongs to another workspace. |
Decisions
Approve or deny a pending request, and manage the auto-tuning model that learns from those decisions.
Decide
POST /api/v1/approvals/{id}/decide
Auth: OWNER or ADMIN only. 403 for any other role.
Request body:
{
"status": "approved",
"comment": "reviewed with staging validation"
}
| Field | Type | Required | Description |
|---|
status | string | Yes | approved or denied. Any other value = 400. |
comment | string | No | Free-form decision rationale. Stored in approvals_queue.decision_comment. |
Response: 200 OK
{
"status": "approved",
"decided_by": "user_123"
}
Errors:
| Status | Condition |
|---|
| 400 | Bad JSON, missing/invalid status. |
| 401 | Not authenticated. |
| 403 | Not OWNER/ADMIN. |
| 404 | Request not found. |
| 409 | Request already decided (status != pending). |
Cancel
POST /api/v1/approvals/{id}/cancel
Withdraw a still-pending request, moving it to the cancelled status. Unlike decide, this records no approve/deny outcome — it marks the gated action moot (mission aborted, agent retired, duplicate request).
Auth: OWNER or ADMIN only. 403 for any other role.
Request body: optional. A bare POST cancels with no reason; a present-but-malformed body is a 400.
{ "reason": "mission aborted" }
| Field | Type | Required | Description |
|---|
reason | string | No | Free-form cancellation rationale. Stored in approvals_queue.decision_comment. |
Response: 200 OK
{
"status": "cancelled",
"cancelled_by": "user_123"
}
Errors:
| Status | Condition |
|---|
| 400 | Body present but malformed JSON. |
| 401 | Not authenticated. |
| 403 | Not OWNER/ADMIN. |
| 404 | Request not found. |
| 409 | Request not pending (already decided or cancelled). |
Reset auto-tuning
POST /api/v1/approvals/reset-auto-tuning
Harbormaster observes operator decisions over time and auto-tunes its rule confidences (a denied request that would have auto-approved next time is a calibration signal). This endpoint resets that learned state to defaults — useful after a major change in policy or after a misconfiguration polluted the auto-tuning model.
Auth: OWNER or ADMIN only.
Request body: requires a non-empty tool field — the reset is scoped to a single tool’s auto-tuning state.
| Field | Type | Required | Description |
|---|
tool | string | Yes | Tool whose rolling reward window to clear. Omitting it (or sending "") returns 400 tool required. |
Response: 200 OK
{
"tool": "Bash",
"rows_deleted": 14,
"workspace_id": "ws_123"
}
rows_deleted is the number of auto-tuning rows wiped for that tool. Idempotent — calling twice in a row returns rows_deleted: 0 on the second call.
Errors: 400 bad JSON or missing tool; 401 not authenticated; 403 wrong role; 500 DB error.
This does not affect already-decided approval rows, journal history, or the rule definitions themselves — only the per-rule confidence that drives auto-mode (async/sync/required) selection.
Journal side-effects
Every write emits a journal entry:
| Action | Entry |
|---|
| Enqueue | approval.requested (info) |
| Decide approved | approval.granted (info) |
| Decide denied | approval.denied (warn) |
| Timeout sweep | approval.timeout (warn) |
| Cancel (agent self-withdraw) | approval.cancelled (notice, actor = agent) |
| Cancel (operator via API/CLI) | approval.cancelled (notice, actor = user) |
See Crew Journal for payload shapes.