Answer a pending chat approval

List the approvals a Chat's latest turn is waiting on, answer one, and confirm the resumed turn.

A Chat turn can pause when it needs a decision: a tool call the Project policy routes to a person, or an action the automation policy gates. This guide starts from an existing Chat, lists the approvals its latest turn is waiting on, answers one, and confirms the turn continued with the Developer API. The Developer API has no chat-creation route, so create the Chat in the product UI first.

Before you start

You need:

  • a Project Chat whose latest turn is waiting on an approval;
  • a personal API key with the automation manage capability, which also covers the automation read capability every Chat read requires;
  • the chat.approval.answer and chat.read action grants. Every Chat-scoped GET route requires chat.read, so the list-then-answer sequence needs both;
  • the projects read capability to read the Project list.

Every Chat route acts as the key's issuing user, so the key must be personal: a bot or service key is refused with 403 actor_not_available before the request is processed.

See API keys to create a personal key, and API overview for capability levels, action grants, and the error contract.

Set the origin and key once for the examples:

export API_ORIGIN="https://api.factorai.ai"
export API_KEY="<your API key>"

Steps

1. Resolve the team, Project, and Chat

Every chat route carries teamId and projectId in its path, so resolve them from the Project list before reading chats:

PROJECT=$(curl -sS "$API_ORIGIN/v1/projects" \
  -H "Authorization: Bearer $API_KEY" |
  jq -c '.projects[] | select(.slug == "my-project")')

TEAM_ID=$(printf '%s' "$PROJECT" | jq -r '.teamId')
PROJECT_ID=$(printf '%s' "$PROJECT" | jq -r '.id')

Then list the Project's chats and select the one waiting on the approval:

CHATS=$(curl -sS \
  "$API_ORIGIN/v1/teams/$TEAM_ID/projects/$PROJECT_ID/chats" \
  -H "Authorization: Bearer $API_KEY")

CHAT_ID=$(printf '%s' "$CHATS" |
  jq -r '.chats[] | select(.title == "Release checklist") | .id')

The response is { chats, offset, totalCount }, newest first. Each chat carries id, title, updatedAt, and sometimes createdAt. Pass limit and offset to page through it.

2. List the pending approvals

GET /v1/teams/{teamId}/projects/{projectId}/chats/{chatId}/approvals returns the approvals the Chat's latest turn is waiting on. It requires the automation read capability and the chat.read action grant.

APPROVALS=$(curl -sS \
  "$API_ORIGIN/v1/teams/$TEAM_ID/projects/$PROJECT_ID/chats/$CHAT_ID/approvals" \
  -H "Authorization: Bearer $API_KEY")

printf '%s' "$APPROVALS" | jq '.approvals'

approvals is a union of two shapes:

{
  "approvals": [
    {
      "kind": "chat_control",
      "requestId": "req_01",
      "title": "Write the release notes",
      "body": "Create docs/releases/v2.md with the changelog.",
      "toolUseId": "toolu_01",
      "draft": true
    },
    {
      "kind": "permission_request",
      "requestId": "req_02"
    }
  ]
}

A chat_control approval carries display text: title, body, the toolUseId it controls, and an optional draft flag. A permission_request carries only kind and requestId; the recorded action it gates is resolved by the same canonical approvals owner the product's approval inbox uses, so it has no title, body, or toolUseId.

3. Choose a decision

The answer body accepts one decision:

DecisionEffect
approveAllow this request once.
alwaysAllow it and remember the grant for the same resource.
denyReject the request.

always records a durable grant for the same resource, so a later request that the same policy gates on that resource is approved without another prompt. Use it only for a resource you trust going forward.

4. Answer the approval

POST /v1/teams/{teamId}/projects/{projectId}/chats/{chatId}/approvals/{requestId}/answer requires the automation manage capability and the chat.approval.answer action grant, and the decision is audited under that action.

REQUEST_ID="req_01"

ANSWER=$(curl -sS -X POST \
  "$API_ORIGIN/v1/teams/$TEAM_ID/projects/$PROJECT_ID/chats/$CHAT_ID/approvals/$REQUEST_ID/answer" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "decision": "approve" }')

printf '%s' "$ANSWER" | jq .

A 201 response carries chatId and, for a chat_control approval, turnExecutionId:

{
  "chatId": "chat_01",
  "turnExecutionId": "turn_01"
}

A permission_request answer returns { "chatId": "chat_01" } with no turnExecutionId: the decision is recorded, and the worker replays the gated write and resumes the turn, so no turn id exists at answer time.

5. Confirm the turn continued

For a chat_control answer, turnExecutionId names the resumed turn. Track it through Workflow and Run lifecycle, or read the Chat's messages for new assistant output:

curl -sS \
  "$API_ORIGIN/v1/teams/$TEAM_ID/projects/$PROJECT_ID/chats/$CHAT_ID/messages" \
  -H "Authorization: Bearer $API_KEY" |
  jq '.messages[-3:]'

GET /v1/teams/{teamId}/projects/{projectId}/chats/{chatId}/messages returns { messages, offset, totalCount }, oldest first. Output that appears after the approval is the resumed turn. For a permission_request, wait for the worker's replay: the resumed output appears in the messages read, not in the answer response.

Errors

Both routes return the same { "error": "<value>" } failure body. The list route produces only the shared read errors; the answer route produces those plus its own answer-only values.

StatusError valueRouteWhen
401-bothThe request is missing a valid API key.
403actor_not_availablebothThe key is not a personal key that can act as a user.
403-bothThe key lacks a capability, an action grant, or Project access.
404project_not_foundbothThe Project does not exist for the key.
404chat_not_foundbothThe Chat does not exist for the key.
404approval_not_foundanswerThe request is no longer pending on the Chat's latest turn.
409chat_scope_conflictbothThe chat's stored scope conflicts with the resolved scope.
409approval_already_answeredanswerThe request was already answered with a different decision.
409approval_dependency_unapprovedanswerA dependency approval this request needs is still unapproved.
422chat_turn_rejectedanswerThe resumed turn was rejected before it ran.
503chat_storage_unavailablebothChat storage or the Chat route's configuration is unavailable.
503chat_execution_unavailableanswerNo durable worker is available to resume the turn.

A - marks a status that comes from the shared authentication or capability layer rather than a chat-route error value. The list route returns 200; the answer route returns 201 and adds 422, and its 404 also covers an approval that is no longer pending. A failed list request never returns approval_not_found, approval_already_answered, approval_dependency_unapproved, chat_turn_rejected, or chat_execution_unavailable, because only the answer route examines a specific request or resumes a turn.

Next steps