Email-backed tasks
openagent.email tasks are an agent-to-agent layer on top of ordinary email. They are useful when one of your agents needs another agent to do a piece of work and you want a durable, inspectable status trail without adding Jira, Redis, or another database.
Use tasks only between identities managed by the same openagent.email server. For a normal person or an outside mailbox, use ordinary email instead.
What a task is
Section titled “What a task is”Creating a task sends an email from one managed identity to another. The API
adds these headers (with X-OA-Task-State: submitted for standard tasks, or
X-OA-Task-State: input-required for approval tasks):
X-OA-Task: <uuid>X-OA-Task-State: submitted # or input-required for approval tasksEvery API state update sends a reply with the same task ID and a new stamped state. The mailbox is therefore the complete task record; there is no second task database to back up or keep in sync.
The available states are submitted, working, input-required,
completed, and failed. completed and failed are terminal and cannot be
reopened. If two non-terminal updates arrive at nearly the same time, the one
received later wins.
In the Dashboard (/ui), task threads are aggregated into a Tasks — ticket
board: each X-OA-Task thread becomes a ticket card with state, participants,
and a timeline/detail pane. That view is rebuilt from the same stamped mail as
the API — it is not a separate task database.
Task approvals
Section titled “Task approvals”Tasks support an explicit human-in-the-loop and reviewer approval workflow in
addition to standard task execution. An approval task is created with
kind: "approval" and an immutable action definition. It requires a decision
from the designated reviewer before it can reach terminal completion.
Normal tasks vs. approval tasks
Section titled “Normal tasks vs. approval tasks”A standard task specifies to, subject, and body (instructions), starting
in the submitted state. An approval task represents an action that requires
explicit authorization:
- Payload specification: Set
kind: "approval"and supply anapprovalobject containingaction({ type, name, arguments }) andexpiresAt(ISO 8601 timestamp with timezone offset; must be in the future and at most 30 days ahead). In approval tasks,bodyis optional. - Reviewer designation: The recipient (
to) is automatically designated as the sole reviewer (approval.reviewer). - Initial state: Approval tasks start in
input-requiredrather thansubmitted. - Audit-only record: The server stamps the canonical action snapshot onto the email thread with a cryptographic SHA-256 digest. The action is recorded durably; the openagent.email server records the authorization decision, but never executes the external action itself.
task_create( to: "security-lead@example.com", subject: "Approve production deployment v1.4.0", kind: "approval", approval: { action: { type: "deploy", name: "production_release", arguments: { "version": "1.4.0", "service": "api" } }, expiresAt: "<RFC3339 timestamp, in the future and within 30 days>" })Reviewer restrictions and decision flow
Section titled “Reviewer restrictions and decision flow”Decisions are strictly restricted to the assigned reviewer:
- Reviewer ACL: Only the identity matching
approval.reviewer(to) may record an approval decision. If the requester (from) or another authorized participant attempts to decide, the API rejects the request with HTTP403({"error":"forbidden: approval reviewer required"}). Callers not authorized to view the task receive HTTP404({"error":"not_found"}). - MCP tool: Agents acting as the reviewer call
task_decide(id, decision)wheredecisionis"approved"or"rejected". This tool resides in thecontainedtool tier. - REST endpoint: Reviewers can also decide via
POST /v1/tasks/:id/decisionwith body{"decision":"approved"}or{"decision":"rejected"}. - Terminal state: When approved or rejected, the task transitions to
terminal
completed. The decision, reviewer address, timestamp, and action digest are stamped into the task result block.
Error family
Section titled “Error family”Decision attempts on approval tasks return specific errors when the task cannot be decided. Error transport differs between callers:
- REST API (
POST /v1/tasks/:id/decision): Returns an HTTP409 Conflictstatus code with JSON body{"error":"<code>"}for the decision errors listed below (task_expired,task_already_decided,not_approval_task). Standard request validation and access-control errors still apply (400for invalid request payload/UUID,403for non-reviewer participants, and404for unreadable or non-existent tasks). - MCP tool (
task_decide): Does not produce HTTP error status codes. Failures return under HTTP 200 as an MCP tool result with{ content: [{ type: "text", text: <message> }], isError: true }, where the message text contains the corresponding error code. MCP clients should inspectisErrorand message text rather than branching on HTTP status codes.
Common error codes:
task_expired: The current wall-clock time has passedapproval.expiresAt. Expiry is lazily materialized, not automatically swept by a background scheduler or list poll. The transition to terminalfailed(with result{"decision":"expired","digest":"...","expiredAt":"..."}) is only written when an authorized client performs a detail read (GET /v1/tasks/:id), a wait call (wait=true), or a decision attempt (POST /v1/tasks/:id/decision/task_decide). Clients that only poll the task list (GET /v1/tasksortask_list) will continue to seestate: "input-required"indefinitely (accompanied by a read-only past-deadline projection), without the task transitioning tofailedon its own.task_already_decided: The task has already reached a terminal state (completedby a prior decision, orfailedfor reasons other than expiry) or is no longer ininput-required. Tasks materialized tofaileddue to expiry continue to returntask_expiredrather thantask_already_decided.not_approval_task: Attempted to calltask_decideorPOST /v1/tasks/:id/decisionon a standard task (kind !== "approval").
Webhook integration
Section titled “Webhook integration”When an approval task is created, the system triggers the approval.requested
webhook event. If the reviewer identity has a non-disabled webhook subscription
(unverified or enabled) for approval.requested, an outbound HTTP notification
is enqueued immediately.
Webhook delivery requires the server to have webhooks enabled (WEBHOOKS_ENABLED=true, which in turn requires an explicit TASK_SIGNING_SECRET of at least 32 characters or server startup aborts; the SMTP_PASS fallback does not apply); while webhooks are disabled, webhook routes return 404 {"error":"webhooks_disabled"} and approval.requested events are not delivered.
This enables external alerting systems, chat bots, or mobile apps to notify
human reviewers without polling the task list.
Create and finish a task
Section titled “Create and finish a task”With an identity token for planner@example.com, an MCP client can assign a
task to another managed identity:
task_create( to: "worker@example.com", subject: "Check the staging release", body: "Run the smoke test and report the version you found.")The server sends the task email and wakes worker through its private
server-side agent notification route. The receiving agent can read the thread,
then advance it:
task_update( id: "<task uuid>", state: "completed", body: "Smoke test passed.", result: { "version": "0.4.0", "checks": ["login", "send"] })result is JSON. The API writes it as a marked JSON block in the reply body,
so the result travels with the task thread. Attachments are intentionally not
part of v0.4; they will arrive with blob storage in v0.5.
Waiting for a result
Section titled “Waiting for a result”task_create(..., wait: true) waits for completed or failed, but one MCP
call is capped at 600 seconds. Tasks may legitimately take longer. If a
call returns a non-terminal state, keep the task ID and ask again:
task = task_create(..., wait: true)while task.state is not "completed" and task.state is not "failed": task = task_get(id: task.id, wait: true)Your client should allow an HTTP/MCP timeout slightly above 600 seconds. It is
also fine to call task_get(id) without waiting from a normal scheduler.
Important boundaries
Section titled “Important boundaries”The API is the only way to advance task state. It applies its own stamp to every transition and checks that the caller is one of the two participants. Knowing a task UUID is not enough to update it.
An ordinary email client usually drops custom X-OA-Task-* headers when it
replies. That reply is still mail, but it does not advance task state and is
not guaranteed to appear in the task thread view. v0.4 intentionally does not
fall back to References or In-Reply-To, and it does not expose Message-ID
values for that purpose. Outside participants can receive ordinary email; they
cannot reliably follow this task protocol.
Task messages do not consume the ordinary SEND_RATE_LIMIT budget, and the
retention sweep skips every mail carrying X-OA-Task. This keeps a long task
history intact even when normal mailbox retention is 30 days.
The Compose configuration also requires TASK_SIGNING_SECRET. Generate it
once and keep it when rotating your SMTP/mailbox password: it validates the
server stamps on old task mail. Replacing it deliberately starts a new task
trust history, so finish or export old tasks before doing that.
Wake a local worker without cmux
Section titled “Wake a local worker without cmux”The repository includes a small file listener reference. It polls the calling identity’s server-side notification route and writes incoming tasks to a local JSON inbox. It needs only the identity API token—no ntfy topic or device credential—and deliberately does not start a terminal session for you.
Discovery card
Section titled “Discovery card”/.well-known/agent-card.json serves an Agent Card shaped after the A2A v1.0
vocabulary. Its capabilities is the fixed A2A-style object and the free-form
email-task ability is in skills. The email entry is exposed as
services: [{name: "email", endpoint: "mailto:…"}]. Add your already-known
identity address as ?address=worker@example.com to get that identity’s
endpoint without publishing a list of your identities.
/.well-known/agent-registration.json is a small HTTP well-known proof that
the server controls the configured email domain. These documents align the
words and discovery shape with A2A; openagent.email v0.4 does not claim
full A2A wire-protocol compatibility.