# Inbox and Outbox
[ApprovalPlugin](https://docs.workflowengine.io/plugins/approval-plugin/overview/) supplies two personal worklists for an approval application. **Inbox** is the user's
"My Tasks" list: it shows documents on which that user can currently act and the available commands. **Outbox** is the user's "Completed
by me" list: it summarizes documents on which that user has completed an approval action.
The application can also query Inbox by process ID to show the current approvers on a document page. ApprovalPlugin supplies the worklist
data; the host application joins each process ID to its business document, renders the screens, and uses
[WorkflowRuntime command APIs](https://docs.workflowengine.io/get-started/concepts/command/) when the user selects a command.
## What happens to Inbox and Outbox after approval
Suppose a purchase request needs approval from a manager and then a boss. The manager first sees the request under My Tasks. After the
manager selects `Approve`, the request appears under the manager's Completed by me list, and the boss receives it under My Tasks.
The purchase request remains the same business document. Approval only changes who needs to act next: the request leaves the manager's My
Tasks list, remains visible under the manager's Completed by me list, and appears in the boss's My Tasks list. Outbox is calculated from
completed Approval history when the application requests it; there is no separate Outbox store.
Use the worklist data for three business screens:
- **My Tasks** - show documents waiting for the current user's action and the actions available to that user.
- **Completed by me** - show one summary for each document on which the current user completed an approval action.
- **Current approvers** - show the people who can currently act on a document.
## Assign approval work
The actor restriction on an outgoing command transition decides which identities ApprovalPlugin writes to `WorkflowInbox`. For a document
assigned to one person, store that person's workflow identity in a process parameter such as `ApproverId`. Define an actor backed by an
`IWorkflowRuleProvider` rule whose selected `GetIdentities` or `GetIdentitiesAsync` method returns that parameter. Its selected `Check` or
`CheckAsync` method must accept the same identity for command authorization. Apply the actor as an `Allow` restriction on the `Approve`
transition. The [rules documentation](https://docs.workflowengine.io/get-started/concepts/rule/) contains a complete provider implementation for this pattern.
For a role or team, return every current member identity from the same rule. ApprovalPlugin creates an inbox row for each returned identity;
it does not create one shared row for the role or team. The rule provider must return the same identity IDs that the application passes to
command authorization and execution.
Query the inbox by process ID when a document screen must show its current approvers. The query returns
[InboxItem](https://api.workflowengine.io/core/OptimaJet.Workflow.Core.Persistence/InboxItem/) records. This example selects only identities
whose stored row contains the requested command:
```csharp filename="CurrentApproverReader.cs"
using OptimaJet.Workflow.Core.Persistence;
using OptimaJet.Workflow.Core.Runtime;
public sealed class CurrentApproverReader
{
private readonly WorkflowRuntime _runtime;
public CurrentApproverReader(WorkflowRuntime runtime)
{
_runtime = runtime;
}
public async Task> GetAsync(
Guid processId,
string commandName)
{
List inbox = await _runtime.PersistenceProvider
.GetInboxByProcessIdAsync(processId);
return inbox
.Where(item => item.AvailableCommands.Any(
command => command.Name == commandName))
.Select(item => item.IdentityId)
.Distinct(StringComparer.Ordinal)
.ToList();
}
}
```
The result is the stored inbox snapshot. Recheck current permission with `GetAvailableCommandsWithConditionCheckAsync` before enabling a
command, and use `ExecuteCommandWithRestrictionCheckAsync` to recheck actor restrictions when executing it. This matters especially when
external role membership can change without workflow execution.
## Show completed work
The "Completed by me" screen shows one summary per document rather than one row for every completed action. An
[OutboxItem](https://api.workflowengine.io/core/OptimaJet.Workflow.Core.Persistence/OutboxItem/) groups approval history by executor
identity and process ID. The provider calculates each summary when queried and orders the results by the most recent approval time.
Table: OutboxItem properties
| Property | Description |
|----------|-------------|
| `ProcessId` | Process whose completed history is summarized |
| `FirstApprovalTime` | Earliest completion time for the identity in that process |
| `LastApprovalTime` | Latest completion time for the identity in that process |
| `ApprovalCount` | Number of matching approval history rows |
| `LastApproval` | Trigger name from the most recent matching history row |
The outbox does not receive rows moved from the inbox. A command can complete or insert an approval history entry, as can `SetState` with
execution enabled. Later outbox queries group records that contain the requested executor identity.
For a command executed on behalf of another workflow participant, outbox grouping uses the initiating `identityId` stored in approval
history. It does not use `impersonatedIdentityId`. See
[delegated command identities](https://docs.workflowengine.io/plugins/approval-plugin/approval-history/#record-delegated-command-identities) for the authorization and
audit split.
## Query inbox and outbox
Use identity-based queries to populate "My Tasks" and "Completed by me". The host application supplies the workflow identity whose work
the screen should show. Use a process-based Inbox query to show the current approvers for one document. Application code accesses these
queries through [IApprovalProvider](https://api.workflowengine.io/core/OptimaJet.Workflow.Core.Persistence/IApprovalProvider/) on
`WorkflowRuntime.PersistenceProvider`.
| Method | Result |
|--------|--------|
| [GetInboxByProcessIdAsync](https://api.workflowengine.io/core/OptimaJet.Workflow.Core.Persistence/IApprovalProvider/#method-getinboxbyprocessidasync-guid-paging-cultureinfo) | Returns inbox rows for one process and optionally localizes command names |
| [GetInboxCountByProcessIdAsync](https://api.workflowengine.io/core/OptimaJet.Workflow.Core.Persistence/IApprovalProvider/#method-getinboxcountbyprocessidasync-guid) | Counts inbox rows for one process |
| [GetInboxByIdentityIdAsync](https://api.workflowengine.io/core/OptimaJet.Workflow.Core.Persistence/IApprovalProvider/#method-getinboxbyidentityidasync-string-paging-cultureinfo) | Returns pending work stored for one identity and optionally localizes command names |
| [GetInboxCountByIdentityIdAsync](https://api.workflowengine.io/core/OptimaJet.Workflow.Core.Persistence/IApprovalProvider/#method-getinboxcountbyidentityidasync-string) | Counts inbox rows stored for one identity |
| [GetOutboxByIdentityIdAsync](https://api.workflowengine.io/core/OptimaJet.Workflow.Core.Persistence/IApprovalProvider/#method-getoutboxbyidentityidasync-string-paging) | Groups completed history into per-process summaries for one executor |
| [GetOutboxCountByIdentityIdAsync](https://api.workflowengine.io/core/OptimaJet.Workflow.Core.Persistence/IApprovalProvider/#method-getoutboxcountbyidentityidasync-string) | Counts the per-process outbox summaries for one executor |
```csharp filename="ApprovalQueueReader.cs"
using OptimaJet.Workflow.Core.Persistence;
using OptimaJet.Workflow.Core.Runtime;
public sealed class ApprovalQueueReader
{
private readonly WorkflowRuntime _runtime;
public ApprovalQueueReader(WorkflowRuntime runtime)
{
_runtime = runtime;
}
public Task> GetInboxAsync(
string identityId,
int pageIndex,
int pageSize)
{
return _runtime.PersistenceProvider.GetInboxByIdentityIdAsync(
identityId,
CreatePaging(pageIndex, pageSize));
}
public Task> GetOutboxAsync(
string identityId,
int pageIndex,
int pageSize)
{
return _runtime.PersistenceProvider.GetOutboxByIdentityIdAsync(
identityId,
CreatePaging(pageIndex, pageSize));
}
public Task> GetProcessInboxAsync(Guid processId)
{
return _runtime.PersistenceProvider
.GetInboxByProcessIdAsync(processId);
}
private static Paging CreatePaging(int pageIndex, int pageSize)
{
if (pageIndex < 1)
{
throw new ArgumentOutOfRangeException(nameof(pageIndex));
}
if (pageSize < 1)
{
throw new ArgumentOutOfRangeException(nameof(pageSize));
}
return Paging.Create(pageIndex, pageSize);
}
}
```
`Paging.Create` uses a 1-based page index. Call `GetInboxCountByIdentityIdAsync`, `GetInboxCountByProcessIdAsync`, or
`GetOutboxCountByIdentityIdAsync` to calculate the number of pages before requesting a page.
Every built-in persistence provider orders inbox results by `AddingDate` descending and outbox summaries by `LastApprovalTime` descending.
Inbox reads populate `CommandName.LocalizedName` from the process scheme and requested culture; only `CommandName.Name` is persisted. When
no translation exists, `LocalizedName` falls back to the technical name.
`Paging.Create` does not validate its arguments. For direct provider calls, pass a page index and page size greater than zero; the workflow
actions disable paging when either configured value is below one.
The built-in provider methods filter inbox rows by process ID or identity ID; they do not join those rows to application business data. If
a worklist must search business fields together with inbox membership, maintain an application-owned search projection.
> **Known limitation - commas in command names:** every built-in persistence provider stores `AvailableCommands` as comma-separated
> technical names. A command name that contains a comma does not round-trip as one item, so use technical command names without commas.
**Tenant scope:** identity-based inbox and outbox methods have no tenant argument. Their results, counts, and paging span every matching
`IdentityId` in a shared provider store. `InboxItem` exposes `TenantId` after reading, but `OutboxItem` does not. Use identity values that
are unique across tenants or a tenant-aware data-access implementation. Identity-based workflow actions inherit the same scope.
## Build worklists through Web API
The built-in Workflow Engine HTTP API exposes stored Inbox and Approval history records. It does not expose
`GetOutboxByIdentityIdAsync` or return the provider's aggregated `OutboxItem` result directly.
An HTTP application has two ways to provide "Completed by me":
- Use the [approval history search endpoint](https://docs.workflowengine.io/workflow-engine-http-api/#tag/Approvals/operation/workflow-api.search.processes.approvals),
filter completed rows by `IdentityId`, and group all matching rows by `ProcessId` in the application.
- Add a host-owned endpoint that calls [GetOutboxByIdentityIdAsync][api-get-outbox]. This returns the same aggregation and paging as a
direct provider call.
[api-get-outbox]: https://api.workflowengine.io/core/OptimaJet.Workflow.Core.Persistence/IApprovalProvider/#method-getoutboxbyidentityidasync-string-paging
Client-side grouping must fetch every matching history row before grouping. Grouping one page cannot reproduce an Outbox page because the
history endpoint pages individual rows first, while `GetOutboxByIdentityIdAsync` groups processes first and then applies paging.
[Workflow Engine HTTP API reference](https://docs.workflowengine.io/workflow-engine-http-api/#tag/InboxEntries) documents the Data API operations that expose stored Inbox
entries for "My Tasks" and current-approver screens.
| Method and path | Result |
|-----------------|--------|
| [POST /workflow-api/search/processes/inbox-entries](https://docs.workflowengine.io/workflow-engine-http-api/#tag/InboxEntries/operation/workflow-api.search.processes.inbox-entries) | Searches inbox rows across processes in the current HTTP tenant |
| [GET /workflow-api/data/processes/{processId}/inbox-entries](https://docs.workflowengine.io/workflow-engine-http-api/#tag/InboxEntries/operation/workflow-api.data.processes.inbox-entries.get-collection) | Returns a filtered, sorted, and paged process inbox |
| [GET /workflow-api/data/processes/{processId}/inbox-entries/{identityId}](https://docs.workflowengine.io/workflow-engine-http-api/#tag/InboxEntries/operation/workflow-api.data.processes.inbox-entries.get) | Returns one process and identity row |
| [DELETE /workflow-api/data/processes/{processId}/inbox-entries](https://docs.workflowengine.io/workflow-engine-http-api/#tag/InboxEntries/operation/workflow-api.data.processes.inbox-entries.delete-collection) | Deletes matching rows for one process |
| [DELETE /workflow-api/data/processes/{processId}/inbox-entries/{identityId}](https://docs.workflowengine.io/workflow-engine-http-api/#tag/InboxEntries/operation/workflow-api.data.processes.inbox-entries.delete) | Deletes one process and identity row |
The search request accepts `search`, nested `filters`, `sorts`, `skip`, and `take`. The response contains `collection` and `total`. This
request returns one page of pending work stored for `manager-42`:
```bash
curl --request POST \
--url "https://example.com/workflow-api/search/processes/inbox-entries" \
--header "Authorization: Bearer $TOKEN" \
--header "Content-Type: application/json" \
--header "Workflow-Api-Tenant-ID: acme" \
--data '{
"filters": [
{
"type": "Equal",
"field": "IdentityId",
"value": "manager-42"
}
],
"sorts": [
{
"field": "AddingDate",
"direction": "Desc"
}
],
"skip": 0,
"take": 20
}'
```
An `IdentityId` filter or route value is request data; Workflow API does not compare it with the authenticated principal. If users may
read only their own inbox, enforce that rule in the host authorization layer or expose a host-owned endpoint that supplies the identity.
HTTP API reads and deletes `WorkflowInbox` records directly. It does not execute `GetInbox` or refresh ApprovalPlugin, and it does not
provide create or update operations. Register ApprovalPlugin in each runtime that must populate the inbox. Deleting an inbox row does not
revoke the identity's current workflow permission; a later inbox refresh can recreate the row.
## Use inbox and outbox actions
The `GetInbox` action performs a process-or-identity query inside a workflow and stores `List` in a process parameter. Use direct
provider calls when application code needs to build a user worklist.
Table: GetInbox action parameters
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `GetParameterType` | Yes | - | Selects `ProcessId` or `IdentityId` |
| `GetParameterValue` | Yes | - | Supplies the process GUID or identity |
| `ParameterPurpose` | Yes in Designer | `Persistence` | Controls how Workflow Engine stores the output parameter; the handler uses the default when it is absent |
| `ParameterName` | Yes | - | Names the output process parameter |
| `PageIndex` | No | `-1` | Uses a 1-based page; values below 1 disable paging |
| `PageSize` | No | `-1` | Enables paging only when both page values are positive |
The `GetOutbox` action performs an identity query and stores `List` in the named process parameter.
Table: GetOutbox action parameters
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `IdentityId` | Yes | - | Selects the identity whose history is grouped |
| `ParameterPurpose` | Yes in Designer | `Persistence` | Controls how Workflow Engine stores the output parameter; the handler uses the default when it is absent |
| `ParameterName` | Yes in Designer | `""` in the handler | Names the output process parameter |
| `PageIndex` | No | `-1` | Uses a 1-based page; values below 1 disable paging |
| `PageSize` | No | `-1` | Enables paging only when both page values are positive |
## Keep Inbox data current
ApprovalPlugin rebuilds inbox rows each time root process execution is about to become idled or finalized. It first deletes existing rows
for the process, resolves actor restrictions on command transitions, and then inserts one `InboxItem` for each selected identity.
`WorkflowInbox` does not store a role or team name as one shared task. Each actor rule resolves its current members to workflow identity
IDs, and ApprovalPlugin considers each returned identity separately. For example, a team rule that returns five members can produce five
rows for the same process, subject to the transition-classifier rules below.
By default, an identity gets a row only when its resolved transitions include at least one `Direct` classifier. When that gate passes,
`AvailableCommands` contains the distinct command names resolved for the identity across all transition classifiers. Setting
`InboxActorsWithOnlyRejectCommands` to `true` also keeps identities with no `Direct` transition, but their command list is empty.
An [InboxItem](https://api.workflowengine.io/core/OptimaJet.Workflow.Core.Persistence/InboxItem/) stores the resulting command names for
one identity and one process. Actor resolution searches the selected process and its descendant subprocesses, but the resulting row uses
the root process ID being refreshed.
Table: InboxItem properties
| Property | Description |
|----------|-------------|
| `Id` | Unique inbox row ID |
| `ProcessId` | Process ID used for the inbox refresh |
| `TenantId` | Tenant associated with the process instance |
| `IdentityId` | Identity that can act |
| `AddingDate` | Runtime date and time when the row was created |
| `AvailableCommands` | Distinct technical command names persisted for the identity; localized names are added when the record is read |
[InboxCheckConditions][api-inbox-check-conditions] determines whether actor resolution also evaluates transition conditions.
[AllowedActorsFilter][api-allowed-actors-filter] receives the names of actors used by `Allow` restrictions. With `Or` concatenation, a
rejected actor is skipped individually. With `And` concatenation, the filter suppresses the transition only when it rejects every allowed
actor; if any actor passes, all allowed actors still participate in the identity intersection. Actor definitions used by `Restrict`
restrictions are never passed to this filter.
Condition evaluation triggered by [InboxCheckConditions][api-inbox-check-conditions] is an actor lookup, not a pre-execution pass, so
`ProcessInstance.IsPreExecution` is `false`. Conditions used for inbox filtering should be read-only and must not send notifications or
write to external systems as a side effect.
[api-inbox-check-conditions]: https://api.workflowengine.io/core/OptimaJet.Workflow.Plugins/ApprovalPlugin/#field-inboxcheckconditions
[api-allowed-actors-filter]: https://api.workflowengine.io/core/OptimaJet.Workflow.Plugins/ApprovalPlugin/#property-allowedactorsfilter
[api-disable-inbox]: https://api.workflowengine.io/core/OptimaJet.Workflow.Plugins/ApprovalPlugin/#property-disableinbox
[api-inbox-reject-only]: https://api.workflowengine.io/core/OptimaJet.Workflow.Plugins/ApprovalPlugin/#field-inboxactorswithonlyrejectcommands
Changing any input to actor resolution does not refresh inbox rows by itself. This includes membership in an external directory, role, or
team; a persisted process parameter such as `ApproverId`; and data read by an actor rule or transition condition. Inbox rows keep the
identities and commands resolved by the previous refresh until later root process execution reaches the refresh point. ApprovalPlugin does
not expose a public method that forces its inbox rebuild for a process.
## Control inbox contents
ApprovalPlugin configuration changes which inbox rows and commands are persisted. `DisableApprovalHistory` does not disable inbox
generation. `DisableInbox` controls inbox maintenance; when both settings are enabled, the runtime handler returns immediately.
Table: Inbox configuration
| Property | Default | Effect |
|----------|---------|--------|
| [DisableInbox][api-disable-inbox] | `false` | Skips future inbox deletion and rebuilding; existing records remain |
| [InboxCheckConditions][api-inbox-check-conditions] | `false` | Adds transition-condition evaluation to actor resolution |
| [AllowedActorsFilter][api-allowed-actors-filter] | `null` | Filters `Allow` restrictions according to their `Or` or `And` concatenation; `Restrict` restrictions are unaffected |
| [InboxActorsWithOnlyRejectCommands][api-inbox-reject-only] | `false` | Keeps an empty-command row when an identity has no `Direct` transition |
### Filter actor definitions
`AllowedActorsFilter` receives actor names, not the identity IDs returned by those actors. For example, a scheme can use
`DocumentApprover` and `Observer` actors in `Allow` restrictions while the worklist should contain only approval assignments. Configuring
`AllowedActorsFilter = actorName => actorName == "DocumentApprover"` excludes the `Observer` actor from inbox calculation for `Or`-joined
restrictions.
`AllowedActorsFilter` changes only the stored inbox projection. It does not revoke workflow permission, filter `Restrict` actors, or replace
the actor checks performed during command lookup and execution. For `And`-joined `Allow` restrictions, the plugin keeps the full identity
intersection when at least one allowed actor passes the filter.
### Keep non-direct actors
`InboxActorsWithOnlyRejectCommands` keeps an inbox row for an identity whose resolved transitions contain no `Direct` classifier. A common
case is an identity that can only return a document through a `Reverse` transition. The retained row has an empty `AvailableCommands` list;
the setting does not persist the reverse command name.
Use the empty row when the application needs to show that the identity is involved with the process even though the main worklist has no
direct action for it. Query current commands from WorkflowRuntime before rendering an action, because the empty inbox row is not an
executable command or an authorization grant.
The plugin deletes current inbox rows before inserting replacements. Consumers should query the provider after workflow execution reaches
an idled or finalized status instead of caching an inbox row as permanent state.
Inbox replacement uses separate delete, actor-resolution, and insert operations. If actor resolution or insertion fails after deletion,
the inbox can remain empty or incomplete until a later refresh succeeds.
## Diagnose an empty inbox
Check the scheme and runtime in this order when `WorkflowInbox` remains empty.
| Check | Expected configuration |
|-------|------------------------|
| Plugin registration | Register ApprovalPlugin with `WithPlugin` before `StartAsync` |
| Inbox switch | Keep `DisableInbox` set to `false` |
| Execution cycle | Let the root process execute and return to an idled or finalized status after plugin registration |
| Command transition | Add an outgoing transition triggered by a command |
| Actor restriction | Apply an actor whose rule resolves at least one workflow identity |
| Rule inputs | Persist required values such as `ApproverId` before actor resolution |
| Transition classifier | Give at least one resolved transition the `Direct` classifier by default |
| Conditions | If `InboxCheckConditions` is `true`, confirm the transition condition succeeds during inbox refresh |
| Direct state change | Use `SetStateAsync` with execution when ApprovalPlugin must refresh; `PreventExecution = true` bypasses the refresh handler and leaves existing rows unchanged |
| Previous approver remains | A role, `ApproverId`, rule input, or condition input changed without workflow execution; revalidate commands and let root process execution reach the refresh point |
After these checks, call `GetInboxByProcessIdAsync(processId)`. An empty result means the refresh produced no identities for the process.
If process-based rows exist but an identity query is empty, compare the exact `IdentityId` returned by the rule with the value used by the
application. A role or team name is not an inbox identity unless the rule deliberately returns that name as an executable identity.
## See also
## Frequently asked questions
**What is stored in the ApprovalPlugin inbox?**
Each `InboxItem` stores one process ID, one identity, the insertion time, and the command names selected during actor resolution.
**Can I read Outbox through Workflow Engine HTTP API?**
The built-in API has no endpoint that returns `OutboxItem`. Search Approval history by `IdentityId` and group all matching records by
`ProcessId`, or expose a host-owned endpoint that calls `GetOutboxByIdentityIdAsync`.
**When does ApprovalPlugin refresh the inbox?**
ApprovalPlugin deletes and rebuilds inbox rows when root process execution is about to become idled or finalized. A direct state change with
`SetStateParams.PreventExecution = true` does not invoke that refresh.
**Can ApprovalPlugin check transition conditions before adding inbox commands?**
Yes. Set `InboxCheckConditions` to `true`. The default is `false`, so actor and command resolution does not evaluate conditions. The check
runs outside pre-execution, so keep those conditions free of side effects.
**Does the ApprovalPlugin inbox refresh when actor data changes?**
No. A role change, `ApproverId` update, or another actor-rule input change does not trigger ApprovalPlugin. Stored inbox rows keep the
previous identity and command resolution until the root process executes again, so applications should revalidate current commands and
maintain a separate live projection when immediate updates are required.