---
title: "Transition a task"
method: POST
path: "/v1/projects/{project_id}/tasks/{task_id}/transitions"
tags: ["Tasks"]
---

# Transition a task

`POST /v1/projects/{project_id}/tasks/{task_id}/transitions`

## Request

- Base URL: `https://api.naturali.ai`
- URL: `https://api.naturali.ai/v1/projects/{project_id}/tasks/{task_id}/transitions`
- Auth: one of:
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://api.naturali.ai/authorize`, token URL `https://api.naturali.ai/token`, scopes: `mcp:access`)

Fires a named transition on a task. The transition must exist in the workflow and be valid from the task's current state; its guard must pass. This is the single path every state change routes through. A transition declaring `requires_approval` does not move the task — it parks a pending ApprovalItem and returns the task with `pending_transition` set; the move applies only when the approval is approved.

## Path parameters

- `task_id` string, required

## Headers

- `Idempotency-Key` string

## Request body

- TransitionTaskRequest
  - `transition` string, required
  - `note` string, nullable
  - `payload` object: Keys shallow-merged over the task's payload under the transition's lock, before the guard reads it, exactly as `PATCH` merges them; the result must satisfy the payload schema of the version the task is pinned to (`TASK_PAYLOAD_INVALID`). A refused move writes none of it. On a `requires_approval` transition the payload is held with the gate and merged when the approval fires the move, so a rejected or expired gate writes nothing.
  - `idempotency_key` string: The move's deduplication key, unique within the task and claimed for as long as the task exists. The same key with the same body replays the task as it is now instead of moving it again, whether the move applied or parked an approval; with a different body it is `409 IDEMPOTENCY_KEY_REUSED`. Equivalent to the `Idempotency-Key` header.
  - `tool_context` object: Caller context for the automation dispatches the task makes from here on, forwarded as `X-Naturali-Context-<key>` headers on their tool calls. Supplying it **replaces** the task's stored bag wholesale; omitting it keeps the current one, so the context follows whoever last moved the task and survives every move that does not speak about it — including an approval gate, a retry, and an automation hop. Send an empty object to clear it without closing the task. The reserved identity keys (`session_id`, `actor_id`, `actor_external_id`) are stripped in any casing and re-derived server-side. A key outside the HTTP header-name grammar is rejected with `INVALID_TOOL_CONTEXT_KEY` (400). Write-only: never returned by a task read, and cleared when the transition closes the task.

## Response `200`

The task after the transition

- Task
  - `id` string
  - `project_id` string
  - `workflow_id` string
  - `workflow_version` integer, nullable: The workflow version this task runs on, fixed when the task was created. Transitions, approval gates and payload validation all resolve through it, so editing the workflow never re-shapes a task already in flight. `null` for tasks created before pinning existed, which run on the live definition.
  - `title` string
  - `state` string
  - `status` 'open' | 'closed'
  - `payload` object: Caller-owned task data; input to guards (as `task.payload`) and dispatch mappings. The engine never writes into it except the workflow's declared `payload_writes`.
  - `metadata` MetadataBag: Caller-owned annotations on a resource, stored as the object they were written as: the types a value was written with are the types a read returns, so a filter can ask an ordering question about a number. Unlike other body fields, keys are stored and returned verbatim in the casing supplied — they are not converted between snake_case and camelCase. No key is reserved, and that is the point: every piece of state the platform owns lives in its own typed column, so nothing written here reaches platform state. The platform never reads the bag — it is not an IAM context, not a policy input and not part of a prompt — which is what separates it from a tag bag.
  - `last_result` unknown
  - `assignee` string, nullable
  - `active_dispatch` object, nullable: { kind, id, status } of the current state's dispatch, if any. `kind` is `generation`, `orchestration_run` or `tool_call`; a `tool_call` always carries a null `id`, since a direct tool call leaves no addressable record. Carries an additional `attempt` (1-based) while the state's `on_enter.retry` policy is in effect.
  - `automation_status` 'running' | 'completed' | 'failed' | 'unrouted' | 'paused' | 'null', nullable: Status of the current state's dispatch. `null` until a state with an automation is entered. `paused` means an operator pause suppressed this state's `on_enter` before it ran, so resume-task will start it.
  - `pause_requested_at` string, date-time, nullable: When an operator paused this task's automation, or null when no pause is in force.
  - `pause_reason` string, nullable: The reason supplied with the pause, when one was.
  - `automation_chain_depth` integer: Server-owned. How many machine-driven transitions have run back-to-back with no outside intervention — a dispatch outcome routed through `on_complete`/`on_failure`, or a `transition-task` call made by a dispatched run or agent with its run-as token. Any move by a person, a plain API key, or an approval resolution resets it to `0`. Once it would exceed the server's limit (`TASK_AUTOMATION_CHAIN_LIMIT`, default 50) the next such transition is refused with `TASK_AUTOMATION_CHAIN_LIMIT`, bounding a cycle composed across workflows and orchestrations.
  - `pending_transition` string, nullable: The name of a `requires_approval` transition parked awaiting a human decision. Non-null while an ApprovalItem gates the move; the task stays in its current state and no other transition may fire until the approval resolves.
  - `entered_state_at` string, date-time
  - `created_at` string, date-time
  - `updated_at` string, date-time

## Other responses

- `400`: The transition does not exist, is not valid, or its guard rejected the move
- `401`: Unauthorized
- `403`: Forbidden
- `404`: Task not found
- `409`: A concurrent transition made this one invalid, the task is closed, or the idempotency key names a different request

---

[API](https://skmtc.dev/naturali/apis/naturali-ai-api.md) · [All operations](https://skmtc.dev/naturali/apis/naturali-ai-api/llms.txt) · [OpenAPI document](https://skmtc.dev/naturali/apis/naturali-ai-api/revisions/e4905b78f422?raw)
