# Thread assignment and claims

Several agents can share one inbox. One assigns a thread. A worker takes an exclusive, expiring claim so two workers do not reply to the same conversation.

The public object is `{object:"thread_assignment", inbox_id, thread_id, assignee, note, claimed_until, claim_active, assigned_at, updated_at}`.

Operator: Avatar 8 LLC (ops@avatar33.com).

## Hand a thread to a named assignee

There is no seats or team model. assignee is a handle the caller chooses: an agent name such as billing-agent, a worker id, or an owner email. It is 1–64 characters: letters, digits, and . _ @ + : -.

PUT /api/v1/inboxes/{id}/threads/{threadId}/assignment stores one row per thread. GET returns {assignment:null} when the thread exists and nobody is assigned. A missing thread is HTTP 404 thread_not_found. Another account's inbox is HTTP 404 inbox_not_found.

Read uses the same permission as GET thread (message_read). Write uses the same permission as PATCH thread labels (message_update). An inbox-scoped key only sees its own inbox.

```bash
curl -s -X PUT "https://cooperemail.com/api/v1/inboxes/$INBOX/threads/$THREAD/assignment" \
  -H "Authorization: Bearer $COOPER_API_KEY" \
  -H "content-type: application/json" \
  -d '{"assignee":"billing-agent","note":"invoice question"}'
```

## A worker pool claims the next thread

claimable=true lists threads that are unassigned or whose claim has expired. A worker then takes an exclusive claim with lease_seconds. The lease is an integer from 30 to 86400. claimed_until becomes now plus that many seconds, and claim_active is true until then.

Two workers that claim the same thread at the same time resolve in one conditional upsert. Exactly one wins. The other receives HTTP 409 thread_claimed. The error includes assignee and claimed_until.

```bash
curl -s "https://cooperemail.com/api/v1/inboxes/$INBOX/threads?claimable=true&limit=20" \
  -H "Authorization: Bearer $COOPER_API_KEY"

curl -s -X PUT "https://cooperemail.com/api/v1/inboxes/$INBOX/threads/$THREAD/assignment" \
  -H "Authorization: Bearer $COOPER_API_KEY" \
  -H "content-type: application/json" \
  -d '{"assignee":"worker-7","lease_seconds":120}'
```

## Renew a claim you already hold

The same assignee can PUT again with a new lease_seconds. That extends claimed_until and does not emit thread.assigned. assigned_at stays the time of the original assignment.

Omit note to keep the current note. Send note null to clear it. A note longer than 500 characters is HTTP 400 invalid_note.

```bash
curl -s -X PUT "https://cooperemail.com/api/v1/inboxes/$INBOX/threads/$THREAD/assignment" \
  -H "Authorization: Bearer $COOPER_API_KEY" \
  -H "content-type: application/json" \
  -d '{"assignee":"worker-7","lease_seconds":120}'
```

## Release the thread when the reply is sent

DELETE returns {assignment:null, released:true} when a row was removed, and released false when nothing was assigned. Pass ?assignee= to release only your own row. An active claim held by someone else, or a plain assignment held by someone else, is HTTP 409 thread_claimed. An expired claim can be released by any assignee.

```bash
curl -s -X DELETE "https://cooperemail.com/api/v1/inboxes/$INBOX/threads/$THREAD/assignment?assignee=worker-7" \
  -H "Authorization: Bearer $COOPER_API_KEY"
```

## Handle HTTP 409 thread_claimed

When the body is thread_claimed, leave that thread and take the next claimable one. force true overwrites an active claim. Use force only when a stuck worker must be replaced. The error object includes assignee and claimed_until.

```bash
code=$(curl -s -o /tmp/cooper-claim.json -w "%{http_code}" \
  -X PUT "https://cooperemail.com/api/v1/inboxes/$INBOX/threads/$THREAD/assignment" \
  -H "Authorization: Bearer $COOPER_API_KEY" \
  -H "content-type: application/json" \
  -d '{"assignee":"worker-8","lease_seconds":120}')
if [ "$code" = "409" ]; then
  python3 -c 'import json; print(json.load(open("/tmp/cooper-claim.json"))["error"]["code"])'
fi
```

## Subscribe to thread.assigned

thread.assigned is recorded when the assignee changes or a new claim starts. A renew by the same assignee does not emit it. thread.unassigned is recorded when a release removes a row. Deleting the thread, the inbox, or the workspace removes the assignment and does not emit thread.unassigned. A new inbound message does not change the assignment.

The event data is the assignment object plus previous_assignee. It does not include a message body. A webhook receives these types only when its events list names them. A hook created without an events list still receives only message.received. Omitting types on GET /api/v1/events, GET /api/v1/events/stream, and a durable event consumer returns every stored type, including thread.assigned and thread.unassigned once they exist. MCP Events stays message.received only.

Lists accept assignee, unassigned=true, and claimable=true on GET /api/v1/inboxes/{id}/threads and GET /api/v1/threads. GET thread, both lists, and thread search include assignment. MCP: cooper_assign_thread, cooper_unassign_thread, and those three inputs on cooper_list_threads. Guide: https://cooperemail.com/docs/thread-assignment.

```bash
curl -s -X POST https://cooperemail.com/api/v1/webhooks \
  -H "Authorization: Bearer $COOPER_API_KEY" \
  -H "content-type: application/json" \
  -d '{"url":"https://example.com/hooks/cooper","events":["thread.assigned","thread.unassigned"],"inbox_id":"'"$INBOX"'"}'
```
