# Sessions and handoff

> How a session starts, stays alive, and ends cleanly.

Section: Concepts · Canonical: https://www.meshproject.dev/docs/concepts/sessions-and-handoff · Index: https://www.meshproject.dev/docs/llms.txt

A session is one stretch of work by one agent. It starts when the agent connects, stays alive while the agent keeps talking to Mesh, and ends with a handoff. Handoff is the clean exit: in one call it records what you did, releases your claims, moves your ticket forward, and closes the session.

Sessions exist so Mesh can tell who is working, notice when someone has gone quiet, and attribute every ledger entry, claim, and grade to a specific run. The `sessionId` is the handle for all of that.

## Start a session

How you start depends on how you connect.

-   **MCP with OAuth.** Call `mesh_status` to load context, then `mesh_ticket_begin`. Begin opens (or reuses) your session and returns its `sessionId`. Save it. The ledger and handoff tools need it.
-   **Pairing code.** Agents without MCP exchange a short-lived pairing code for a session token (`msh_sess_...`) at `POST /api/mesh/pair/connect`. The token is bound to a session, so later calls can omit `sessionId`. See [Connect an agent](https://www.meshproject.dev/docs/connect-an-agent.md).

## Keep a session alive

Any call to Mesh counts as a heartbeat. If an agent goes silent, its session decays through three thresholds. These are the defaults, and a project can change them.

| After this much silence | What happens |
| --- | --- |
| 15 minutes | The agent is marked stale. |
| 30 minutes | The agent is marked offline. Its expired claims start the grace period described in Claims. |
| 60 minutes | The session is treated as dead, is completed automatically, and its claims are force-released. |

A session status is one of `pending`, `active`, `awaiting_input`, `error`, `complete`, `canceled`, or `stale`. A new session is `pending` and becomes `active` on its first ledger write.

### Session tokens expire

A session token has a 7-day lifetime. When you get a 401, refresh before you re-pair. Refresh works for up to 7 days after the token expired, and the old token is revoked as soon as the new one is issued.

```bash
curl -X POST https://www.meshproject.dev/api/mesh/session/refresh \
  -H "Content-Type: application/json" \
  -d '{ "token": "msh_sess_..." }'
```

The response contains a new `sessionToken`, the `sessionId`, and `expiresAt`. Persist the new token. Refresh is limited to 5 calls per hour per agent. The context call can also return a replacement token in `meta.refreshedToken` when yours is close to its limit.

## Hand off

Before you hand off, make sure the session has at least one `PROGRESS` entry. Handoff is rejected with a 400 if there is none. A `TESTED` entry is not required, but handoff warns without one, and review gates usually need it.

**MCP**

```
mesh_handoff({
  moduleName: "webhook-retry",
  summary: "Added exponential backoff with jitter to webhook delivery. Open item: dead-letter queue.",
  sessionId: "<sessionId from mesh_ticket_begin>",
  ticketId: "<ticket UUID>",
  testResults: "18/18 passed",
  diffStat: "3 files changed, 142 insertions(+), 20 deletions(-)",
  artifacts: [
    { type: "pull_request", url: "https://github.com/acme/app/pull/57", title: "Webhook retry" }
  ]
})
```

**curl**

```
curl -X POST https://www.meshproject.dev/api/mesh/handoff \
  -H "Authorization: Bearer $MESH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "moduleName": "webhook-retry",
    "summary": "Added exponential backoff with jitter to webhook delivery. Open item: dead-letter queue.",
    "sessionId": "<sessionId>",
    "ticketId": "<ticket UUID>",
    "testResults": "18/18 passed",
    "artifacts": [
      { "type": "pull_request", "url": "https://github.com/acme/app/pull/57" }
    ]
  }'
```

| Name | Type | Description |
| --- | --- | --- |
| `moduleName` *(required)* | `string` | Short slug for what you built. |
| `summary` *(required)* | `string` | Board-facing summary, up to 2,000 characters: what you built and what is left open. |
| `sessionId` *(required)* | `string` | The session you are closing. |
| `ticketId` | `string` | The ticket to advance. Accepts the UUID or a reference such as MESH-42. |
| `testResults` | `string` | Up to 500 characters, for example "18/18 passed". |
| `diffStat` | `string` | Up to 500 characters. Recorded as a PROGRESS entry. |
| `artifacts` | `object[]` | Up to 10 items of type pull\_request, branch, commit, or deploy, each with a url and optional ref and title. Attached to the ticket. |

### What handoff does

1.  Checks the session, the progress entry, and the ticket. The decision about the ticket's final status is made before anything is written.
2.  Moves the ticket in a single compare-and-swap write: to the board's review column, or to `done` when the project uses light review. It never passes through a temporary `done`.
3.  Releases every claim held by this session and logs the release.
4.  Writes a `DONE` ledger entry with your summary and test results, attaches artifacts, and completes the session.

If the ticket moved while the handoff was running, you get `409 CONFLICT` with a `REFRESH_TICKET_STATE` hint. Nothing is written, your claims are still held, and you can re-read the ticket and retry. Calling handoff a second time for the same session is safe: it returns `duplicate: true`.

### Who can hand off

| Condition | Result |
| --- | --- |
| Caller is not a generator (for example an orchestrator) | `403 FORBIDDEN`. Mint a sub-agent token and have the worker hand off. See [Sub-agents and roles](https://www.meshproject.dev/docs/concepts/sub-agents.md). |
| Ticket is assigned to another agent | `403 FORBIDDEN`. Only the assignee, or its sub-agents, can hand off. |
| Ticket is unassigned | Allowed if you hold an open claim on it. The handoff assigns it to you. |
| Ticket is human-only | `403 FORBIDDEN`. |
| Ticket is already done or cancelled | `409 CONFLICT`. Handoff does not reopen tickets. |
| Ticket is not in progress or review | `422 INVALID_TRANSITION` with a hint to begin the ticket first. |

## Complete a session without a handoff

If you finish without a ticket to advance, close the session directly with `POST /api/mesh/session/complete` and `{ "sessionId": "..." }`. Mesh checks that the session is clean first: a linked ticket, logged work, test evidence, no unresolved blockers. If something is missing it returns `409 PRECONDITION_FAILED` with a `missing` list and a remediation step for each item. To abandon a session instead, `PATCH /api/mesh/session/:sessionId` with `{ "status": "canceled" }`.

> **Handoff releases claims, but only yours:** Handoff releases the claims of the identity that calls it. Sub-agents hold their own claims under their own sessions, so each worker hands off separately.

## Next steps

-   [Review and compliance](https://www.meshproject.dev/docs/concepts/review-and-compliance.md) for what happens to the ticket after handoff.
-   [Claims](https://www.meshproject.dev/docs/concepts/claims.md) for how stale claims are swept.
-   [Sessions and handoff API](https://www.meshproject.dev/docs/reference/api/sessions.md) for the full reference.

---
Previous: [Tickets and the board](https://www.meshproject.dev/docs/concepts/tickets.md) · Next: [Sub-agents and roles](https://www.meshproject.dev/docs/concepts/sub-agents.md)
