Pending Actions (Human-in-the-Loop)

Review and approve AI-proposed writes before they land. Every @gated tool call produces a pending action that users can approve, edit, or reject.

Overview

Praxiom AI's agents can persist insights, save recommendations, draft documents, and push issues to GitHub, Linear, and Google Drive. Letting a model run those writes unattended is how you end up with hallucinated tickets, fabricated recommendations, and cluttered third-party tools.

The Human-in-the-Loop (HITL) layer solves this by intercepting every write-classified tool call before it executes. Instead of running, the tool records its intent as a pending action — a row in the database with the exact tool input and a render-ready preview. The user then reviews the proposed write from an inbox and decides whether it lands.

HITL is on by default for every write tool in the Praxiom agent SDK. There is no "trust this tool" bypass for end users — approval is always required before a gated tool produces side effects.

The @gated Decorator

Every write-classified tool handler is wrapped with the @gated(preview_fn=...) decorator from app.core.sdk.tools._gated. At call time the decorator does four things:

1

Extract identity

Pull workspace_id, mission_id, and session_id out of the tool input kwargs. workspace_id is required; the others are optional.

2

Build preview

Call the tool's preview_fn(tool_input) to produce a render-ready dict for the approval UI. Preview functions are pure and must not raise.

3

Record intent

Persist a PendingAction row via PendingActionService.record(...) with status = "pending", the full tool_input, and the preview.

4

Return queued signal

Hand the agent back a {"status": "queued", "pending_action_id": "...", "tool_name": "...", "message": "..."} payload instead of executing. The agent knows the call did not run and can adjust downstream tool use accordingly.

The real handler function is preserved as wrapper.__original__ so the approval executor can run it for real once a user approves.

Gated Tools

These tool handlers currently live behind @gated. Each one records intent instead of executing directly until a user approves.

ToolSourcePurpose
save_insightsapp/core/sdk/tools/synthesis.pyPersist synthesised insights to the workspace
save_recommendationsapp/core/sdk/tools/recommendations.pyPersist feature recommendations with effort/impact scores
save_documentapp/core/sdk/tools/drafting.pyPersist a drafted document (PRD, spec, etc.)
create_github_issueapp/core/sdk/tools/integration.pyOpen an issue on a selected GitHub repo
create_linear_issueapp/core/sdk/tools/integration.pyCreate an issue on the selected Linear team
update_linear_issueapp/core/sdk/tools/integration.pyMutate an existing Linear issue
create_linear_commentapp/core/sdk/tools/integration.pyPost a comment on a Linear issue
google_drive_writeapp/core/sdk/tools/integration.pyCreate or overwrite a Google Drive file
google_drive_updateapp/core/sdk/tools/integration.pyUpdate a specific Google Drive file
google_drive_batch_importapp/core/sdk/tools/integration.pyBulk-import a Drive folder into Praxiom sources

Read-only tools (search, fetch, list) are intentionally not gated. Gating read paths would add friction without any safety benefit — reads cannot fabricate data on your behalf.

Pending Action States

Every pending action row moves through this lifecycle:

StatusMeaning
pendingRecorded by @gated, awaiting user action
approvedUser approved; execution has been scheduled
rejectedUser rejected; the handler will never run
executedApproved action ran successfully; result is populated
failedApproved action ran but the handler raised; error is populated

Only pending rows can be approved or rejected. Attempting to resolve an already-resolved row returns 409 INVALID_STATE.

Approval Flow

Single approve

POST /api/pending-actions/{pa_id}/approve flips the row to approved, records optional user_edits, commits, and then schedules execution as a fire-and-forget asyncio task. The HTTP response returns as soon as the row is committed — the user does not wait on the downstream API call.

{
  "user_edits": {
    "recommendations": [
      { "title": "Streamline onboarding", "effort_weeks": 2 }
    ]
  }
}

The user_edits dict is shallow-merged into tool_input at execution time. Use it to correct titles, trim descriptions, or swap metadata before the handler runs.

Batch approve

When the agent queues many similar writes in one pass (for example, five recommendations in a single save_recommendations call or ten Linear issues), they share a batch_id of the form {mission_id}:{tool_name}. A single batch endpoint resolves them together:

POST /api/pending-actions/batch/{batch_id}/approve

{
  "items": [
    { "pendingActionId": "pa-1", "userEdits": { "priority": 2 } },
    { "pendingActionId": "pa-2" },
    { "pendingActionId": "pa-3", "exclude": true }
  ]
}

Per-item semantics:

  • Listed with no exclude → approve (optionally with edits).
  • Listed with exclude: true → reject.
  • Not listed at all → left pending (default-skip).

The default-skip rule is intentional: it prevents silent data loss if the frontend forgets to include an item in the payload. Any row you want resolved must appear in items.

The response summarises counts:

{ "batchId": "mission-uuid:save_recommendations", "approved": 2, "rejected": 1, "skipped": 0 }

skipped counts rows that another session resolved between your list and your mutate — partial progress is preserved instead of 500ing.

Reject Flow

POST /api/pending-actions/{pa_id}/reject flips the row to rejected and sets resolved_at. The underlying handler is never invoked; no downstream side effect occurs. Rejected rows stay in the database as an audit trail of what the agent proposed and what the user refused.

There is no free-text rejection reason field today. If you need to capture rationale, surface it in the UI layer and store it alongside the conversation message.

Lifecycle and Access Control

  • Who can approve: any member of the owning workspace. Every route calls _verify_workspace_access — rows cannot be seen or mutated across tenants. A non-member hitting a valid pa_id gets 403 FORBIDDEN.
  • Expiration: pending actions do not auto-expire today. They persist in pending state until a workspace member resolves them. Future iterations may add a TTL; plan for it but don't depend on it.
  • Commit-then-schedule: approvals commit the DB row first, then schedule the executor. If the process restarts between commit and execution, the row is orphaned in approved state. A reconciler will re-enqueue such rows in a future task — see docs/superpowers/plans/2026-04-14-hitl-pending-actions.md Task 7.
  • Execution semantics: the executor reaches past the @gated wrapper via handler.__original__, merges user_edits into tool_input, and runs the raw handler. Any exception is caught and recorded on the row as failed; it never crashes the event loop.

UI Surfacing

Pending actions surface in two places in the product:

  1. The pending-actions panel — mounted at the thread level in ConversationMessage, showing the list of queued writes for the active mission with per-item approve / edit / exclude controls. Batch approve resolves the whole group in one request.
  2. Conversation resumption (briefing event) — when the user reopens a conversation, the SSE briefing event includes counts of outstanding pending actions so the UI can remind the user that writes are waiting on them before the agent can make further progress.

At subtask boundaries inside a mission, the HITL checkpoint manager (see app/core/harness/hitl.py) collects the pending_action_batch_ids produced during the subtask and hands them to the checkpoint state so approve / reject can cascade across batches in one user decision.

Example Payload

A typical save_recommendations pending action looks like this when fetched via GET /api/pending-actions/{pa_id}:

{
  "id": "fb93a0ea-2d1c-4f3d-8b3a-7e31d5f4bc11",
  "workspaceId": "550e8400-e29b-41d4-a716-446655440000",
  "missionId": "a1b2c3d4-e5f6-7890-abcd-ef0123456789",
  "sessionId": "ses_abc123",
  "batchId": "a1b2c3d4-e5f6-7890-abcd-ef0123456789:save_recommendations",
  "toolName": "save_recommendations",
  "toolInput": {
    "workspace_id": "550e8400-e29b-41d4-a716-446655440000",
    "recommendations": [
      {
        "title": "Streamline onboarding step 3",
        "description": "Collapse the two-screen verification...",
        "addresses_insight_ids": ["ins-1", "ins-2"],
        "effort_estimate": "small",
        "effort_weeks": 2,
        "impact_score": 0.82,
        "success_metrics": ["Activation rate +10%", "Drop-off <8%"]
      }
    ],
    "prioritization_rationale": "Quick wins first — onboarding friction..."
  },
  "preview": {
    "count": 1,
    "prioritization_rationale": "Quick wins first — onboarding friction...",
    "items": [
      {
        "title": "Streamline onboarding step 3",
        "description": "Collapse the two-screen verification...",
        "rationale": "",
        "effort_estimate": "small",
        "effort_weeks": 2,
        "impact_score": 0.82,
        "addresses_insight_ids": ["ins-1", "ins-2"],
        "success_metrics": ["Activation rate +10%", "Drop-off <8%"]
      }
    ]
  },
  "status": "pending",
  "userEdits": null,
  "result": null,
  "error": null,
  "createdAt": "2026-04-21T18:02:11.420Z",
  "resolvedAt": null,
  "executedAt": null
}

Key Concepts

  • Intent, not execution — a pending row is a captured intent. Nothing happens to your workspace, GitHub, Linear, or Drive until a human approves it.
  • Same input, same handler — the executor runs the exact same function the agent would have run. Approval is a gate, not a reimplementation.
  • Edits are merged, not replaceduser_edits is a shallow dict merge over tool_input. Supply only the keys you want to change.
  • Batches share a workspacebatch_id encodes {mission_id}:{tool_name}, and a mission belongs to a single workspace. Cross-workspace batches are rejected as an invariant violation.
  • Rejected is final — there is no "undo reject". Re-run the agent if you want another proposal.

What's Next

See the Pending Actions API reference for the full endpoint contracts, request bodies, and curl examples.

Was this helpful?