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:
Extract identity
Pull workspace_id, mission_id, and session_id out of the tool input kwargs. workspace_id is required; the others are optional.
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.
Record intent
Persist a PendingAction row via PendingActionService.record(...) with status = "pending", the full tool_input, and the preview.
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.
| Tool | Source | Purpose |
|---|---|---|
save_insights | app/core/sdk/tools/synthesis.py | Persist synthesised insights to the workspace |
save_recommendations | app/core/sdk/tools/recommendations.py | Persist feature recommendations with effort/impact scores |
save_document | app/core/sdk/tools/drafting.py | Persist a drafted document (PRD, spec, etc.) |
create_github_issue | app/core/sdk/tools/integration.py | Open an issue on a selected GitHub repo |
create_linear_issue | app/core/sdk/tools/integration.py | Create an issue on the selected Linear team |
update_linear_issue | app/core/sdk/tools/integration.py | Mutate an existing Linear issue |
create_linear_comment | app/core/sdk/tools/integration.py | Post a comment on a Linear issue |
google_drive_write | app/core/sdk/tools/integration.py | Create or overwrite a Google Drive file |
google_drive_update | app/core/sdk/tools/integration.py | Update a specific Google Drive file |
google_drive_batch_import | app/core/sdk/tools/integration.py | Bulk-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:
| Status | Meaning |
|---|---|
pending | Recorded by @gated, awaiting user action |
approved | User approved; execution has been scheduled |
rejected | User rejected; the handler will never run |
executed | Approved action ran successfully; result is populated |
failed | Approved 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 validpa_idgets403 FORBIDDEN. - Expiration: pending actions do not auto-expire today. They persist in
pendingstate 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
approvedstate. A reconciler will re-enqueue such rows in a future task — seedocs/superpowers/plans/2026-04-14-hitl-pending-actions.mdTask 7. - Execution semantics: the executor reaches past the
@gatedwrapper viahandler.__original__, mergesuser_editsintotool_input, and runs the raw handler. Any exception is caught and recorded on the row asfailed; it never crashes the event loop.
UI Surfacing
Pending actions surface in two places in the product:
- 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. - Conversation resumption (
briefingevent) — when the user reopens a conversation, the SSEbriefingevent 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
pendingrow 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 replaced —
user_editsis a shallow dict merge overtool_input. Supply only the keys you want to change. - Batches share a workspace —
batch_idencodes{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?