Human review inbox

One product page for every agentic action that is paused because a person must authorize an exact proposal. It is a view over the shared approval primitive, not a second review system.

1 min read Updated Aug 31, 2026

Human review inbox

Status: accepted · Date: 2026-08-30

A dedicated product page where a person can see and resolve work the agentic platform has intentionally paused for human authorization.

The product name is Human Review. The underlying platform surface is the Approval Inbox.

The key rule is:

If a Run is waiting because a person must approve or reject an exact proposed action, the approval appears here.

Human Review does not create a new workflow engine, task system, notification system, policy engine or approval store. It reads and resolves the same agent.approvals rows used by Policy and Runtime.


Product promise

A user should be able to open one page and answer four questions immediately:

  1. What needs my decision?
  2. What exactly will happen if I approve?
  3. Why was human review required?
  4. Which Run is waiting for this decision?

Human Review is the guaranteed channel-independent fallback. In the current V1 slice, approval decisions are presented in web chat and this inbox. A later remote channel, such as Slack, may render the same decision, but every presentation resolves the same approval row.


What belongs here

V1 review types

Review typeExampleCreated at
Run admissionA wide or costly Signals Search, if current admission Policy requires approvalRunManager.start() → Policy admission checkpoint
External side effectSend an Email Sequence message to a new recipientTool Invoker → Policy action checkpoint
Sensitive CRM changeMove a protected customer lifecycle stateTool Invoker → Policy action checkpoint
Authored gateA workflow that must pause for a person before outreachWorkflow approval node

There are three V1 approval entry points. All three use ApprovalService, write agent.approvals, move the same Run into waiting, and therefore appear in the same inbox.

raised_byWho decided a person is needed
admissionPolicy, before a Run executes anything
actionPolicy, before a gated tool effect executes
nodeThe workflow author, when the definition was written

raised_by is not PolicyRequest.checkpoint. The policy checkpoints are admission, action and accrual, and the engine never emits node, because a node approval is not a policy decision at all. Two overlapping vocabularies under one field name would read as one, and the audit trail would then claim policy required a gate the author wrote by hand.

The third one is not a policy decision, and that is the point. A rule can say "an email to a new recipient needs approval". It cannot say "always stop here, in this workflow, whatever the arguments are". An author who wants a fixed review gate declares an approval node, and the runtime creates the same row.

The inbox does not care which of the three created the row. raised_by records it for the audit trail.

The Front Door does not decide approval conditions or raise its own approval. It delegates through StartRunCommand / RunManager.start(). RunManager owns the Run; Policy then decides allow, deny or require_approval.

Future review types

A future product feature may reuse the same approval primitive when it needs an explicit authorize/reject decision. For example, an agent-proposed durable-memory write could use Human Review, but that has not been made a V1 requirement.

Do not add a new review queue merely because a new product needs a human decision.

What does not belong here

ItemWhere it belongs
Agent needs missing information from the userOriginating conversation
Failed or unhealthy RunRun Explorer
General notificationsProduct notification system
Work someone should manually performTask system
Policy configurationAdmin policy settings
Signals Search prospect curation: new / watching / dismissed / promotedSignals Search / Opportunity Review

The Signals Search review state is business curation over an Organization Prospect. It is not execution authorization and must not be folded into agent.approvals.

Human Review is a decision inbox, not a general work inbox. Every row here blocks a Run. A queue a person may ignore without stopping work belongs somewhere else.


Page structure

TEXT
Human Review
┌─────────────────────────────────────────────────────────────────────┐
│ [Pending]  [History]                                                │
├────────────────────────────────────┬────────────────────────────────┤
│ Send email to Sarah Chen           │ email.send                     │
│ email.send · requested 4m ago      │                                │
│ Expires in 56m                     │ Workflow: Email Sequence       │
│                                    │ Target: Sarah Chen · Acme      │
│ Search up to 5,000 companies       │                                │
│ signals.search · requested 12m ago │ Why review is required         │
│ Expires in 44m                     │ Wide run requires approval     │
│                                    │ Rule ids, behind a disclosure  │
│ Change lifecycle: Customer → ...   │                                │
│ crm.company.update · 20m ago       │ Proposed action                │
│ Expires in 40m                     │ ┌────────────────────────────┐ │
│                                    │ │ exact proposed action      │ │
│                                    │ └────────────────────────────┘ │
│                                    │                                │
│                                    │ Run: open in Run Explorer      │
│                                    │ [Reject]           [Approve]   │
└────────────────────────────────────┴────────────────────────────────┘

List pane

Each row shows what the list projection carries, and nothing else:

  • the row title, from preview, then target_summary, then action;
  • action;
  • the requested time;
  • the expiry state.

⚠️ The row names no Agent and no Workflow. The list projection holds run_id alone, so a name costs one GET /runs/{run_id} for each row. One page holds fifty rows. The detail pane reads the run for the selected row instead.

Detail pane

The detail contains enough information to decide without reading the span tree:

  • the Agent or Workflow name, from one GET /runs/{run_id} on the selected row;
  • action, the exact proposed action;
  • target_summary, what the call acts on;
  • proposed_arguments, the proposed content or change;
  • reason, why a person was asked;
  • matched_policy_ids, the rules that asked;
  • run_id, a link to the Run Explorer detail;
  • created_at and expires_at;
  • Approve / Reject.

⚠️ There is no risk category and no severity label. No column holds either one. action names the effect, and reason names the cause.

V1 definitions do not have a revision/history subsystem. Do not show an agent_version. The originating Run already freezes the effective execution snapshot used by the work, so the review links to that Run for stable execution identity.

Run Explorer remains the place for spans, detailed execution history and debugging.


Core interaction

Admission approval

TEXT
Front Door / Trigger
       │
       ▼
StartRunCommand
       │
       ▼
RunManager.start()
       │
       ├─ create/freeze Run execution snapshot
       ▼
Policy admission
   ┌───────┼──────────────┐
   ▼       ▼              ▼
 allow    deny     require_approval
   │       │              │
 dispatch stop       agent.approvals
                         │
                         ▼
                    Run = waiting
                    dispatch, and wait
                         │
                         ▼
                    Human Review
                         │
                    approve/reject
                         │
                         ▼
                  the wait resolves
                         │
                   execute / stop

The Front Door only requested the Run. It does not own the approval state or waiting lifecycle.

An admission approval dispatches its Inngest function immediately, and the function waits before its first step. That is what gives the approval an expiry owner: the wait timeout is the writer that marks it expired. A waiting Run holds no worker. See runtime execution.

Action approval

TEXT
Agent / Workflow
      │
      ▼
proposed Tool call
      │
      ▼
Tool Invoker → Policy action
      │
      └─ require_approval
             │
             ▼
      persist exact proposal
      + continuation identity
             │
             ▼
      agent.approvals row
      Run = waiting
             │
             ▼
        Inngest wait
             │
      worker may disappear
             │
             ▼
        Human Review
        approve/reject
             │
             ▼
   fresh worker may resume
   from durable Run state
             │
             ▼
   revalidate → execute/reject

An approval never executes a tool directly from the UI. The UI resolves the approval row. Runtime/Inngest own waiting and continuation; the Tool layer owns the eventual side effect.

Everything needed to resume must be durable before the worker enters the wait. A resumed worker uses the Run's frozen execution snapshot plus persisted continuation identity/state; correctness never depends on the original in-memory Agno object surviving.


Resolution and execution rules

  1. Approve means approve this exact proposal. The stored arguments/content hash is checked again before execution.
  2. Target state is revalidated. For a write/send, if the relevant target state changed while waiting, fail closed and require a fresh approval rather than applying stale authorization.
  3. Reject is explicit. Silence never becomes consent.
  4. Expiry is explicit, and it has an owner. Every pending approval has a TTL, and an expired approval cannot execute. The Inngest wait timeout resolves the row to expired and releases the Run. A list query may derive the expired state from expires_at before that fires, so the queue never shows a dead row. No sweeper job exists, because every terminal state has a real writer: a person writes approved or rejected, the wait timeout writes expired, and RunManager.cancel(), RunManager.fail() and the segment supersede write cancelled. An expired approval cannot execute, and a row a person answered inside its TTL still executes after the timeout fires: the execution check reads resolved_at, never the clock.
  5. Approval cannot increase authority. The resolver must hold the permission needed for the underlying action.
  6. Resolution is one atomic conditional state transition. pending → approved | rejected | expired allows only one winner. A later resolver receives the already-resolved state.
  7. Approval and idempotency solve different problems. Approval answers may this action happen? The shared Idempotency Service answers has this approved effect already happened?
  8. Writes and sends remain idempotent after approval. If a worker dies after a vendor accepts the effect but before local completion is recorded, retry must return/recover the prior result rather than repeat the effect.
  9. Any supported surface resolves the same approval id. Human Review and interactive channel actions are alternate presentations, not alternate decisions.
  10. One Run may hold several approvals, in sequence or at once. An email sequence reaches several approvals one after another over a long Run. A workflow parallel node reaches several at the same time, because each branch waits in place and its siblings keep running. Both are ordinary.
    The inbox lists each one as its own row, and they resolve in any order. The Run's waiting_ref_id names the oldest unresolved one, so a person opening the Run sees one thing to do, and the Run leaves waiting only when the last of them is answered. See runtime execution.

Product/API view

Human Review is a view over the platform approval domain, not a new data model.

The API projection needs enough information to render the decision safely:

TEXT
ApprovalDetail
  id
  run_id                the Run a reader opens for the Agent or Workflow name
  root_run_id           the top of the Run tree; a cancel filters on it

  raised_by             admission | action | node
  action                the tool name, or the definition action
  target_summary?       what the call acts on, in words a person reads
  preview?              the line a handler's approval_preview rendered
  reason?               why a person was asked

  proposed_arguments    the exact content the model proposed; redacted on read
  arguments_hash        which call this approval covers
  matched_policy_ids    the rules that asked; empty for a `node` approval

  status                pending | approved | rejected | expired | cancelled
  created_at            when the row was filed
  expires_at
  resolved_at?
  resolved_by?          the person who decided; null after that account is deleted

⚠️ organization_id is a filter and never a field. The token names the organization, and the repository applies it to every read. A response that echoed it would tell a caller nothing it did not send.

The list row is the same shape without reason, proposed_arguments, arguments_hash, matched_policy_ids, root_run_id and the two resolution fields. A person triages on the action, the target, the request time and the expiry. reason is a sentence, so it reads in the detail and not in a row.

⚠️ status is derived, and the stored column is not always the answer. A row that reads pending and whose expires_at has passed answers expired. The wait timeout is the writer of that column, and it has not fired yet.

The projection carries no continuation and no idempotency_key. Both are runtime plumbing.

There is no definition_id and no action_category. agent.runs pairs its definition key with organization_id and kind, and this table holds no kind, so a paired key has no target here. A single column key would let an approval name another tenant's definition. The Run answers the definition instead.

There is a policy column, since ENG-2169. matched_policy_ids records every rule that matched the decision the row was filed for. ToolInvoker reads it before an approved call runs (check 6 in policy and governance), and reason names the winner only in words, so a person who wants the rule itself needs the id. It carries no foreign key: a deleted rule must not take the record of what it once gated.

root_run_id is denormalized here for the same reason agent.spans carries it: cancelling a Run must clear its pending approvals in one statement, and a filter listing five hundred child Run IDs is an 18 KB URL that PostgREST answers with 414. It is kept true by the composite foreign key to the Run, never by a copy.

idempotency_key is a column and not a field of the projection. It is the tool journal key, <run_id>:<step_path>:<args_hash>, and the uniqueness is what stops a replayed segment filing the same proposal twice. A pending approval is a proposed effect rather than an executed one, so agent.idempotency_keys does not hold it and this row carries the guard instead. See runtime execution.

The durable execution identity is the Run and its frozen snapshot. Human Review must not create a parallel definition or version record.

Do not introduce a human_reviews table.


API contract

Human Review already speaks the AgencyCore schema, so it calls the approval domain API directly. It does not pass through Channel Gateway, and it gets no ApprovalInboxService UI-specific backend layer.

Surfaces owns the routes and the API rules. Policy and governance owns what one resolution checks, and what writes each terminal state. This page adds nothing to either, and it must not restate them.

One consequence is worth naming here, because it shapes the page. A repeated resolution returns the current resolved state. That atomicity is not side-effect idempotency, and the execution path still uses the shared Idempotency Service.


Filtering and ordering

V1 stays small.

Default ordering

One sort key: expires_at ascending, then id. The soonest expiry comes first, and id breaks a tie two approvals of one parallel node share.

⚠️ The tie break is not "oldest first". id is a random UUID, so two rows of one expiry come back in an arbitrary but stable order. Expiry order is not request order either: the TTL of the rule and the deadline of the run both set the expiry, so a later request can expire first. Surfaces holds the rule.

Filters

V1 ships status plus the cursor, and status defaults to pending. The queue is short, so category filters wait until a customer needs them.

No saved views, team queues or custom routing in V1.


Notifications and presentation surfaces

Human Review is always available, but an approval may first be surfaced elsewhere.

TEXT
approval created
      │
      ├── Human Review: always available
      │
      └── interactive channel presentation, when supported
            ├── web chat
            ├── Slack
            └── other Channel Gateway adapters as capabilities allow

Interactive channel buttons go through the Channel Gateway's deterministic action handling and resolve the same approval id.

Email is not a Channel Gateway channel. Nylas owns email transport and email Tools/events. Email Sequence may show a product banner/link such as Review draft that opens Human Review, but email approval does not become a Channel Gateway path.

Do not build a second approval-notification system for this feature.


Rules the page follows

Five rules, and each one answers a way the page can lie to a person.

  1. The response is the truth, and the page never assumes a decision won. Three rows answer 200 with a status nobody chose: a row whose expiry passed, a row another surface resolved, and a row a Run cancelled. The page renders the status the response carried. An optimistic update tells a person they approved work that never ran.
  2. The Approve button never reads the browser clock. The two clocks differ. A button that a skewed clock disables hides a row the API resolves. The countdown is decoration, and the request is the test.
  3. A node approval names no rule. matched_policy_ids is empty and reason may be null, because no rule produced the decision. The detail then says a workflow author required the review.
  4. The row title has one precedence: preview, then target_summary, then action. A tool that declares no approval_preview leaves preview null. action is the one field that is never null.
  5. proposed_arguments is unbounded. It holds an email body. The pane caps the height and puts the rest behind a disclosure.

The inbox has no live channel. A Run stream is keyed on one Run, and this page lists the approvals of many Runs. So the page reads the list again: after each decision, and on an interval while the tab has focus. V1 adds no organization channel for this.


Empty, stale, restart and race states

StateProduct behavior
No pending approvalsShow a simple empty state: "Nothing needs review."
Approval expiredRemove from default queue; show as expired in History
Run cancelledApproval is non-actionable/cancelled
Another surface resolved itRender the status the response carried; do not present it as an error
Proposal changedBlock execution and require a fresh approval
Target state changedFail closed and require a fresh approval when appropriate
User lacks permissionV1 checks visibility alone, so every member may decide. The scope of the approver is checked before the effect runs, and a call it does not cover is refused there
Worker restarted while waitingNo user-visible failure; a fresh worker resumes from durable Run/continuation state after resolution
Worker dies after approved vendor effectShared idempotency recovers/returns the prior effect result instead of repeating it

No distributed lock is required for approval resolution. The conditional pending transition decides the winner.


History

The primary page is pending work. A lightweight History tab provides audit confidence without becoming Run Explorer.

⚠️ History reads one status at a time, and it is not a merged feed. The list takes one status value, so four statuses need four requests and four cursors. Four ordered pages cannot merge into one. History is therefore a status selector over the same list: approved, rejected, expired, cancelled. It defaults to approved.

⚠️ History reads the oldest expiry first. expires_at ascending is the one sort key of the list, and it serves the pending queue. A newest-first audit needs a second index, and V1 adds none.

⚠️ A History row carries the list projection, and nothing more. The list answers ApprovalSummary, which holds no resolved_at, no resolved_by and no reason. A row therefore shows the same four fields the pending queue shows, and a reader opens the row for the decision:

  • the row title, from preview, then target_summary, then action;
  • action;
  • created_at, the request time.

⚠️ A resolved row announces no expiry. It keeps the expires_at it was filed with, and that time has usually passed. Expired on an approved row reads as though the approval died, which is the opposite of what happened. A row the timeout resolved still reads Expired, because there the word is the decision.

The detail pane answers the rest, because it reads ApprovalDetail:

  • resolved_at, the resolution time;
  • resolved_by, resolved to a name through the organization profiles the app already loads. The column is null for expired and for cancelled, and null again after the account is deleted;
  • reason;
  • run_id, the originating Run.

The detailed technical execution remains in Run Explorer. Human Review does not duplicate the span tree.


Example flows

A. Email Sequence sends to a new recipient

TEXT
Email Sequence Run
  -> draft message
  -> email.send proposed
  -> Policy(action) = require_approval
  -> persist exact send + continuation
  -> agent.approvals
  -> Run waiting / Inngest wait
  -> Human Review: recipient + subject + body + reason
  -> Approve
  -> TTL + authority + argument hash + target state rechecked
  -> IdempotencyService reads the journal, then claims the send effect
  -> Nylas send executes once
  -> same Run resumes

The same Email Sequence Run may later reach another approval for a follow-up. That creates a new approval row; it is not a new Run merely because another human decision is required.

B. Signals Search has a wide admission gate

TEXT
User asks for wide Signals Search
  -> Front Door selects Signals Search
  -> StartRunCommand
  -> RunManager.start()
  -> create Run + freeze execution snapshot
  -> Policy(admission)
       ├── allow -> dispatch
       └── require_approval
             -> approval row
             -> Run waiting
             -> Human Review
             -> Approve
             -> the Inngest wait resolves, and the same Run executes

The 5,000-company scope does not inherently require approval. It appears here only when the organization's current admission Policy says it does.

No Agent/Tool execution is spent before admission approval. The Run is created, its snapshot is frozen, the deterministic policy decision runs, and the dispatched function parks on the wait. A parked wait holds no worker and costs nothing.

This is separate from reviewing the resulting Organization Prospects. Prospect watch / dismiss / promote actions remain in Signals Search / Opportunity Review.

C. Two surfaces race to resolve

TEXT
Slack approval button ─┐
                       ├─► same approval id
Human Review page ─────┘
                              │
                              ▼
                    conditional pending update
                       ┌──────┴──────┐
                       ▼             ▼
                    winner       already resolved
                       │             │
                       ▼             ▼
                  signal wait    return state

This guarantees one human decision transition. The eventual Tool side effect has its own Idempotency key and retry protection.

D. Worker restarts while approval waits

TEXT
Tool proposal
  -> persist approval + continuation identity
  -> Run waiting / Inngest wait
  -> worker terminates
  -> user approves later
  -> new worker loads Run frozen snapshot + continuation
  -> revalidate
  -> idempotent execution
  -> Run continues

Human Review does not need to know which worker originally requested the approval.


Product boundary

Human Review owns the human decision experience.

It does not own:

  • why review is required: Policy owns the decision and reason;
  • Run creation or admission: RunManager owns Product Run state;
  • waiting/resume: Runtime + Inngest own durable coordination;
  • the side effect: Tools & Integrations own execution;
  • duplicate-effect protection: Idempotency owns it;
  • interactive channel rendering: Channel Gateway owns web/Slack/etc. presentation;
  • email transport: Nylas/email integration owns it;
  • run debugging: Run Explorer owns it;
  • business/prospect review state: CRM and product workflows own it.

That boundary lets every current and future Agent/Workflow share one review experience without creating parallel approval, runtime or product-state systems.

Not building in V1

Do not build these yet:

  • bulk approval;
  • approval delegation / reassignment;
  • comments or discussion threads;
  • editing proposed actions inside Human Review;
  • custom review routing;
  • multi-stage approval chains;
  • per-team queues;
  • SLA/escalation engine;
  • a second notification system;
  • a second approval table;
  • definition-version/history UI inside a review;
  • a generic manual-work/task inbox.

If the proposed action needs to change, reject it and run the originating work again, so the new proposal receives its own authorization.