> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nitsor.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Every page in this documentation carries a `plans` list in its frontmatter and opens with a plan marker naming the plans its content applies to: `All plans`, or a subset of `Community`, `Team` and `Enterprise`. Community is free and self-hosted. Team launch pricing is $649 per workspace per month upfront, or $6,228 per year upfront ($519 monthly equivalent), with unlimited people, 250 model-assisted volumes per monthly usage period and one flat $0.25 per additional volume. Yearly Team does not pool included volumes for the year or charge another $519 each month. Extra usage settles after each monthly billing cycle. Paid signup and charging under this Team policy are not available. Billing remains deferred, with no paid-launch date set. Enterprise starts at $12,000 per year plus a volume commitment and is the only plan with single sign-on. Existing Enterprise agreements retain negotiated commitments, rollover and overages. Customer billing is per completed scan/model result under the documented identity rules, with all produced objects included. Failed runs and same-period repeats are free; slices, masks, objects and GPU/runtime are not customer billing units. Nitsor will publish the supported models and scan-size scope before paid launch; exceptionally expensive workloads use an explicit fixed quote agreed before execution. Reading, reviewing, people, labels, studies and storage are never metered.
> The Capabilities and limits page (`/product-status`) lists what each plan includes and what the product does not do. If a statement elsewhere disagrees with it, that page is correct.
> Reading this documentation grants no product access and no permission. No agent can create or approve a Nitsor release.

# Service accounts and tokens

> Create and manage service accounts, the identity a script, a scheduler or an agent acts as: tokens and expiry, permissions, the owner, disable, enable and retire, and how an agent reads its own access.

[Team and Enterprise](/plans-and-billing#compare-plans)

A service account is the identity a script, a scheduler, a CI job or an agent acts as in a workspace. It has a name, a description, an owner, the account permissions it holds and its own project access. Its tokens prove it. A token can be added, replaced or revoked without changing who the job is, so project access and history stay with the service account, and the service account stays with the workspace when a person leaves.

An owner or admin manages service accounts on **Team settings**, then **Service accounts**. Service accounts are created in the hosted dashboard on Team and Enterprise. A Community deployment has no dashboard and no supported way to create one. [Integrations and public interfaces](/reference/integrations) shows what a new token looks like and how to use it.

## Who can do what

| Role | On Team settings, then Service accounts |
| - | - |
| Owner or admin | Create service accounts, edit them, add, roll, shorten and revoke their tokens, choose owners, disable, enable and retire. |
| Member | Open the list and every service account's page. A notice says only an owner or admin can change them, and no control is offered. |
| Billing | Denied the page. |

Giving a service account access to a project is a project admin's act, in one of two places. On that project's Access page the service account is a **Service account** row beside people and model Agents. Choose it from **Person or Agent**, pick an access level up to **Operator** and choose **Add to project**. The picker offers every active service account that holds no access on that project yet, whether or not it has a token. A disabled or retired service account is not offered. Its existing row stays, marked **Disabled** or **Retired**, with its level frozen (**Operator. Enable the service account to change this.** or **Operator. Retired, so it can only be removed.**) and **Remove** still available. Allowing a service account to delete Dataset Versions in the project still needs a live token that reaches it.

A project admin can also list, grant, change or remove a service account's access to that project through the API's project-service-account routes or the Python SDK's `service_accounts.project_access`, under a personal token that holds admin on the project. Nitsor does not issue personal tokens today, so these calls cannot succeed yet. See [Manage from the API and the Python SDK](#manage-from-the-api-and-the-python-sdk).

## What a service account holds

| Part | Rule |
| - | - |
| Name | 1 to 100 characters. Two service accounts that are not retired cannot share a name, whatever the letter case. Renaming changes nothing else: the identity, tokens and project access stay. |
| Description | Optional, up to 500 characters. Shown in the list. Saving it empty clears it. |
| Owner | One active member who answers for it. See [Owner](#owner). |
| Permissions | The account permissions it holds. Tokens inherit them or are narrower. See [Permissions](#permissions). |
| Project access | Grants a project admin gives on each project's Access page, at **Operator** at most. |
| Tokens | Up to 10 live tokens. See [Tokens](#tokens). |
| State | **Active**, **Disabled** or **Retired**. See [Disable, enable and retire](#disable-enable-and-retire). |
| Identity | A fixed id, shown as **Identity** on the Overview. It names the service account in the audit trail and on every project's Access page, and it never changes when a token is replaced. The API returns it as `subject`, and `GET /api/v1/session` returns it as `principal.subject`. |

Nothing is ever deleted. A retired service account stays in the list, and History and Dataset Versions keep naming it.

## Create a service account

Choose **New service account** on Team settings, then Service accounts. The form has four steps, and nothing is created until the last one.

| Step | What you set |
| - | - |
| **Purpose** | **Name** (required), **Description** (optional), **Owner** (you, unless you choose another member with **Change owner**) and a **Template**. |
| **Permissions** | The account permissions the service account holds, grouped by area, each with a line on what it allows. Choose at least one. You can only grant permissions you hold. |
| **First token** | Whether to create a token now, which is on by default, and the [token options](#add-a-token), except narrowing to projects: a service account being created is granted no project, so the step says its first token will reach each project a project admin grants it later. |
| **Review** | What the service account will be able to do, its description, the first token and the owner. **Create service account** creates it. |

A template only preselects the next step. Nothing about it is stored.

| Template | Preselects | What it's for |
| - | - | - |
| **Read-only** | `account:read`, `projects:read`, `storage:read` | Reads account details, projects and storage; to export Dataset Versions, also give it Source access and project access. |
| **Register sources** | `projects:read`, `storage:read`, `sources:register` | Registers new scans from a storage connection and retries refused reads. |
| **Custom** | Nothing | Start with nothing selected. |

The API and the Python SDK have no templates: a script passes the permission list itself.

If you turn the first token off, the service account exists with its permissions and cannot authenticate until you add a token. The finish screen then says the service account is ready and holds its permissions and no token yet, and **What next** ends with **Add a token when you are ready.** A project admin can give it project access on a project's Access page before it has a token. The access works as soon as a token exists.

Creating a service account grants it no project access. It sees no project until a project admin gives it access on the project's Access page (project **Settings**, then **Access**), at **Operator** at most.

When it is created with a first token, the page shows that token once, covered, with **Copy token**, **Show token** and the **Use it** snippets, and a **Service account created.** message confirms it. Nitsor keeps only a hash of the token, so reloading the page cannot show it again. A create that was retried after a lost response does not create a second service account, and the page says the first token's secret cannot be shown. Add a token from the service account's page instead. The **What next** card links to each project's Access page when the workspace has one to three projects, and to the account home otherwise.

## The list

**Team settings**, then **Service accounts**, lists them newest first, 25 to a page. The filter above the table shows **All**, **Active**, **Disabled** or **Retired**, with counts when the workspace has 200 service accounts or fewer.

| Column | Shows |
| - | - |
| **Name** | The name, as a link, with the description under it. |
| **Status** | Active, Disabled or Retired, each a shape and a word. |
| **Owner** | The owner's name, or the name and **(left the account)** when the owner has left. |
| **Permissions** | A count such as `3 read · 1 write`, or **None**, with the list on hover or focus. A permission counts by what it does, not by its name: one that only looks is a read, `sources:browse` among them, and one that changes something is a write, `work:contribute` among them. |
| **Projects** | How many projects it has been granted. |
| **Tokens** | The live tokens, such as `2 active`, or **No tokens**, and **expires in N days** when the soonest expiry is within 30 days on an active service account. |
| **Last used** | The latest use of any live token, recorded to the minute, or **Never used**. |

On a narrow screen each service account is a card: its name and state, the description, the same permissions count and one line with its tokens, any expiry warning and its last use. With no service accounts the page explains what one is and offers **New service account** to an owner or admin.

## A service account's page

Choose a name to open its page. It has six sections.

| Section | What it holds |
| - | - |
| **Overview** | Name, description, owner, who created it and when, status, and the identity with a copy control. **Edit** turns name and description into fields. **Change owner** opens the member list. A disabled or retired service account shows a notice with who did it, when and the reason. |
| **Permissions** | The account permissions it holds, grouped by area. **Edit permissions** changes them. |
| **Tokens** | Every live token, the **Add token** control and, folded under **Show N expired or revoked tokens**, the dead ones. |
| **Project access** | The projects it has been granted and its access level on each, read-only, each linking to that project's Access page. A disabled service account's grants read **not in force while disabled**, and a retired one's read **kept for audit**. |
| **Activity** | The last use of each live token. This is not a full event log. |
| **Danger zone** | **Disable** or **Enable**, and **Retire**. |

A saved edit shows a message: **Name and description saved.** or **Permissions saved.**

## Tokens

A service account holds up to 10 live tokens, so a job can move without an outage: add a token for the new deployment, switch over, then revoke the old one. At 10, **Add token** is switched off and says so. **Roll** stays available at 10, because it ends the token it replaces.

The token list shows these columns.

| Column | Shows |
| - | - |
| **Label** | The token's name, with **Stored in** and its storage note under it when you gave one. |
| **Hint** | `••••a3f9`, the last four characters of the token, to match a token you find in a config file to its row. It is not a secret. A token created before hints existed reads **Unavailable**. |
| **Reach** | **Inherits** when the token carries no limits of its own, so it follows the service account's current permissions, including any added later, and reaches every project it is granted. **Narrowed** when it carries its own list of permissions, a nonempty list of projects or both, even a permission list that equals the service account's list today. An empty project list narrows nothing. |
| **Created** | The date and who created it. |
| **Last used** | Recorded to the minute. A token used several times in one minute shows the first of those uses. **Never used** until its first call. |
| **Expires** | The expiry date. A live token with 30 days or fewer left is marked **Expiring soon** with the time left. |
| **Status** | **Active**, **Expiring soon**, **Stopping** (replaced and still inside its overlap, with its cutoff), **Expired** or **Revoked** (with who revoked it, when and why). **Blocked while disabled** appears under a live token of a disabled service account. |

The list names the replacement chain: a token that was rolled says what replaced it, and the new one says what it replaced. The page reads the 200 most recent tokens of any state and says so when older ones may not be listed.

### Add a token

Choose **Add token**, at the top of the service account's page or in the **Tokens** section. **Add token** is not offered while the service account is disabled or retired. The same options appear as the third step of the create form.

| Option | Rule |
| - | - |
| **Label** | Optional. The token's name in the list, which tells deployments apart, such as `github-actions`. It defaults to the service account's name. |
| **Expires** | 7, 30, 90 or 365 days, or **Custom**, a date from tomorrow to 365 days from today. 90 days is selected. |
| **Where will you store it?** | Optional, up to 200 characters. A note, not a secret, shown beside the token so the next person knows where it lives. |
| **Narrow to projects** | Optional, at most 32 projects, chosen from the projects the service account is granted, because a token reaches no project its service account does not. By default a token reaches every project the service account is granted. A line counts how many are selected. With more than eight projects the list scrolls and gains a filter box, and a selected project stays listed whatever you type. When the service account holds more grants than the dialog lists, the box searches the rest. With no grant at all the dialog says the service account is not granted any project, so the token cannot be narrowed, and that it will reach each project a project admin grants later. |
| **Narrow permissions for this token** | A switch. Off, the token can do everything the service account can. On, it holds only the permissions you tick, chosen from the service account's own list, and at least one. |

A token can do at most what its service account can. The new token is shown once, covered, like any new token, and an **added** message confirms it. A token's own list and its project limit never change after it is made. To change them, make a new token.

### Expiry

Every token expires, 90 days after creation unless you choose another time, and never more than 365 days out.

**Shorten** on a live token moves its expiry earlier, never later, to a preset that falls before the current expiry or a custom day. The token keeps working until the new cutoff, and the [leases](/reference/glossary#glossary-terms-and-common-misreadings) it already issued stop working within one hour after it: they refuse new reads. A download already under way can finish. To extend a token, roll it. **Shorten** is offered while the service account is disabled. The API's shorten takes any instant in the future that is not later than the current expiry. The one-hour floor applies to the expiry of a new token, not to a shorten.

### Roll a token

**Roll** replaces a token with a successor. Choose an **Overlap** from no overlap to 24 hours in whole hours (24 hours is selected) and confirm with **Roll token**. The successor has the same label and the same reach, a 90-day expiry, and is shown once. The replaced token keeps working until the earlier of its own expiry and the end of the overlap, then stops: a token that expires in ten minutes stops in ten minutes whatever overlap you choose. Revoke it early once your deployments use the new token. After a roll the old and the new token share a name until the old one stops, so name the old token by its id when you revoke it through the API.

**Roll** is offered on a live token that has no successor and only while the service account is active. A successor cannot be rolled until the token it replaced has stopped, and **Roll** is switched off and says when that wait ends. A token that already has a successor is not rolled again: roll the newer one. A token narrowed to a project can be rolled only while the service account still holds a grant on that project, because a roll never restores a removed grant.

### Revoke a token

**Revoke** opens a confirmation with an optional reason of up to 500 characters. Anything using the token is refused from then on. Leases it already issued stop working within one hour, and a download already under way can finish; data a job has already downloaded stays with it. Revoking is permanent: a revoked token stays in the list for audit, with who revoked it and why, and cannot be restored. **Revoke** is offered while the service account is disabled.

## Permissions

The service account holds a list of account permissions. Every token inherits it unless the token was made narrower, and a token's own list is an upper bound. What a request can do is what the service account holds and the token presented both allow, and every request uses only the token it presented. Project access is separate: account permissions give no access to any project.

| Area | Permission and what it allows |
| - | - |
| Account | `account:read` sees the account's name, plan and settings. `account:update` changes them. |
| Members | `members:read` sees who is on the account and their roles. |
| Billing | `billing:read` sees invoices and usage. |
| Storage and Sources | `storage:read` sees storage connections and reads scans through them. `storage:write` checks, adds and changes them. `sources:browse` browses scans in every project it reads, beyond the Tasks it holds. `sources:register` registers Sources, retries refused reads and rebuilds viewer representations. |
| Projects and work | `projects:read` sees the projects it has been granted and their Dataset Versions. `projects:write` creates projects and changes their settings. `work:contribute` claims, completes and reviews annotation work in granted projects. |
| Notifications | `notifications:read` reads account notifications. `notifications:write` changes notification settings. |

Wherever you pick permissions, a wide page sets each read beside the write it pairs with and a narrow page stacks them, so you see at once whether a token may only look or may also change. A service account can never hold `members:write`, `billing:write`, `clearance:write` or `service-accounts:manage`. The forms show them switched off with the reason, so they cannot be ticked. The API refuses a list that names one and creates or changes nothing: as `token_scope_escalation` when the caller's token does not hold the permission, which is checked first, and as `service_account_permission_ineligible` when it does. A token's own list of permissions (a first token's, or one chosen in Add token) is checked the other way round: eligibility first, so a forbidden permission there is refused as `service_account_permission_ineligible` even when the caller's token does not hold it, then the service account's list, then the caller's reach. You can only give a service account permissions you hold yourself; when you edit the list, only the permissions you add must be ones you hold.

**Edit permissions** on the **Permissions** section changes the list. The dashboard needs at least one permission; the API accepts an empty list, a service account that reaches only what its project grants give it. Before you save, the page says what the change does.

* Adding a permission reaches every inheriting token at its next request.
* Removing a permission takes effect for new requests at once. Leases already issued that relied on it stop working within one hour: their further reads are refused, and a download already under way can finish.

Editing permissions is allowed while the service account is disabled, so the list is right when you enable it, and refused once it is retired.

A person holds **Manage service accounts** (`service-accounts:manage`) to manage service accounts through the API and the Python SDK. Owners and admins hold it through their role. A personal token carries it only when its person holds it and the token lists it. A service account can never hold it, so a service account cannot manage service accounts.

## Disable, enable and retire

**Danger zone** holds the two stops. **Disable** is a pause you can undo. **Retire** is permanent. Nothing is deleted by either.

| | Disable | Enable | Retire |
| - | - | - | - |
| What it does | The control plane refuses every request from the service account from the moment of the disable. Leases already issued stop working within one hour: they refuse new reads. A download already under way can finish, and data a job has already downloaded stays with it. | Its live tokens work again on their next request. | Revokes every token that is live at that moment, with the reason **Service account retired**, and ends the service account for good. |
| Tokens | Kept, not revoked. | Only tokens that are still unexpired and unrevoked work again. A token that expired or was revoked while it was disabled stays dead. | Live tokens are revoked. A token that had already expired stays expired, and one revoked earlier keeps its own reason and revoker. The service account's tokens are refused whatever their state. |
| Project access | Kept, not in force while disabled. A project admin cannot give a disabled service account new access. | In force again. | Kept for audit and not in force. Project admins can still see and remove it. |
| Running work | A job stops at its next step. | Nothing restarts on its own. A person starts the job again. | Stops. |
| Delegated actions | Rights given for a limited time, such as deleting Dataset Versions in one project, end. A project admin gives them again if they are still needed. | Not revived. | End. |
| Name and history | Unchanged. | Unchanged. | History and Dataset Versions keep naming it, marked retired. The name is free for a new, unrelated service account. |
| Still allowed | Rename, describe, edit permissions, change owner, revoke or shorten a token. Not allowed: add or roll a token, give it project access. | | Reads, and revoking a token that is not yet revoked, an expired one included (through the API, since an expired token has no control in the dashboard). Every other change is refused. |

**Disable** takes an optional reason of up to 500 characters, kept in the audit record. **Enable** and **Retire** take none in the dashboard, and **Retire** asks you to type the service account's name exactly as shown. The API accepts a reason for all three. A disabled or retired service account shows a notice naming who did it, when and why, and **Disabled** or **Retired** appears in the list. Disabling a service account that is already disabled changes nothing. A retired service account is refused every change except revoking a token, enable included.

An agent or script that presents a token of a retired service account gets 401 `AUTH_INVALID_TOKEN` with the `reason` `service_account_retired`. A token of a disabled service account gets `service_account_disabled` when the token is otherwise live; one that is also revoked or expired keeps that reason first, because `service_account_disabled` is given only when an enable would bring the token back. So an agent can tell a pause from an ending. [When a token is refused](#when-a-token-is-refused) says what to do with each. The service account's page shows an owner or admin its state.

## Owner

Every service account has one owner, an active member of the workspace in any role. The owner is the person to ask about it: they do not give it its access, and changing the owner changes no permission, token or project access.

* The default owner is the person who creates it. Choose another member in step 1 of the create form.
* Through the API and the Python SDK, a member is named by a membership id or by the email they sign in with. The server matches an email against the workspace's active members without regard to case, so the member need not own a service account yet. An email asks the roster who holds an address, so it needs a token that also holds `members:read`, which owners and admins hold; without it the server refuses with `owner_email_needs_members_read` and reads no member. A membership id needs no more than `service-accounts:manage`. An email no active member has is refused as `owner_not_member`, whether it was never a member's or its member left. An email that two members share, or on a roster too large to search whole (over 2,000 memberships, past members included, where even a single match is unresolved), is refused as `owner_email_unresolved`, and then the membership id names the member. A refusal changes nothing.
* **Change owner** on the Overview opens the list of active members. A long roster adds **Search members by email**.
* When the owner leaves the workspace, the service account keeps running. Nothing is paused and no token changes. Owners and admins see one row on **Home**, under **Needs you**: "2 service accounts need a new owner." Its **Open Service accounts** link opens the list, and **Which service accounts** under it names each one: "Choose a new owner for nightly-export. Its owner left the account; it keeps running." A disabled service account stays disabled, and choosing an owner does not enable it. **Choose owner** opens the member list. A retired service account raises no row. If the row can be counted but its service accounts cannot be named, Home says "Which service accounts need a new owner could not be loaded." with a retry control. When more service accounts need an owner than the list shows, a last line says so, and the rest appear as these are cleared.
* The list shows **(left the account)** beside an owner who has left, until someone names a new one.

## Manage from the API and the Python SDK

The API and the Python SDK do what the dashboard does with service accounts and their tokens, under the same server rules. They need a personal token that holds **Manage service accounts**, except the `project_access` calls, which need a personal token that holds `admin` on the project and no account permission. Nitsor does not issue personal tokens today, so these calls cannot succeed. Manage service accounts in the dashboard. A service account's own token never manages a service account: every one of these routes refuses it with `AUTHORIZATION_DENIED` (403) naming `service-accounts:manage`.

| Client | Where it is described |
| - | - |
| HTTP API | [Service accounts](/reference/api#service-accounts): the thirteen routes, request fields, answers and refusal reasons. |
| Python SDK | The methods below. |

The Python SDK is not on PyPI. These forms are what its tests use.

```python theme={"system"}
from datetime import datetime, timedelta, timezone

from nitsor import Nitsor
from nitsor.types import (
    ProjectServiceAccountsGrantRequest,
    ProjectServiceAccountsRemoveRequest,
    ServiceAccountsCreateRequest,
    ServiceAccountsLifecycleRequest,
    ServiceAccountsSetOwnerRequest,
    ServiceAccountsSetPermissionsRequest,
    ServiceAccountTokensCreateRequest,
    ServiceAccountTokensReplaceRequest,
    ServiceAccountTokensRevokeRequest,
    ServiceAccountTokensShortenRequest,
)

with Nitsor() as client:
    created = client.service_accounts.create(
        ServiceAccountsCreateRequest.model_validate(
            {
                "accountPermissions": ["account:read", "projects:read", "storage:read"],
                "description": "Exports every night",
                "firstToken": {"expiresInDays": 90},
                "idempotencyKey": "create-nightly-export-1",
                "name": "nightly-export",
                # With neither ownerEmail nor ownerMembershipId, the caller is the owner.
                # Name another member by the email they sign in with ("ownerEmail") or by
                # a membership id ("ownerMembershipId"), never both.
                "ownerEmail": "leon@example.com",
            }
        )
    )
    secret = created.token.secret  # shown once, on this answer only. Store it before anything else.

    # A project admin gives nightly-export access to proj_1 on that project's Access page
    # first. Rolling a token narrowed to a project is refused until the grant exists.
    added = client.service_accounts.tokens.create(
        created.serviceAccount.id,
        ServiceAccountTokensCreateRequest.model_validate(
            {
                "accountPermissions": ["projects:read"],
                "idempotencyKey": "add-dashboard-token-1",
                "name": "dashboard",
                "projects": ["proj_1"],
            }
        ),
    )

    # The successor has the same name. The old token stops after the overlap or at its own
    # expiry, whichever is earlier, and the old token's id is how you name it from here on.
    rolled = client.service_accounts.tokens.replace(
        added.token.id,
        ServiceAccountTokensReplaceRequest.model_validate(
            {"idempotencyKey": "roll-dashboard-token-1", "overlapMs": 3_600_000}
        ),
    )
    # rolled.token.secret is shown once, on this answer only. rolled.previous.stopsAt says
    # when the old token stops.

    # Shorten the successor to a cutoff earlier than its 90-day expiry.
    cutoff = (datetime.now(timezone.utc) + timedelta(days=30)).isoformat()
    client.service_accounts.tokens.shorten(
        rolled.token.id, ServiceAccountTokensShortenRequest.model_validate({"expiresAt": cutoff})
    )

    # Once the deployment uses the successor, revoke the predecessor by its id.
    client.service_accounts.tokens.revoke(
        added.token.id, ServiceAccountTokensRevokeRequest.model_validate({"reason": "rotated"})
    )

    client.service_accounts.set_permissions(
        created.serviceAccount.id,
        ServiceAccountsSetPermissionsRequest.model_validate({"accountPermissions": ["projects:read"]}),
    )
    # Exactly one of ownerEmail and ownerMembershipId.
    client.service_accounts.set_owner(
        created.serviceAccount.id, ServiceAccountsSetOwnerRequest(ownerEmail="maya@example.com")
    )
    client.service_accounts.disable(
        created.serviceAccount.id, ServiceAccountsLifecycleRequest(reason="incident")
    )
    client.service_accounts.enable(created.serviceAccount.id)
    for account in client.service_accounts.list(status="active").auto_paging_iter():
        print(account.name, account.status, account.liveTokens.count)

    # A project admin's token grants, changes or removes nightly-export's access to proj_1.
    # These calls need a personal token that holds admin on proj_1; Nitsor does not issue
    # personal tokens today, so they cannot succeed yet.
    client.service_accounts.project_access.grant(
        "proj_1",
        ProjectServiceAccountsGrantRequest.model_validate(
            {
                "idempotencyKey": "grant-nightly-export-proj_1-1",
                "rung": "operate",
                "serviceAccountId": created.serviceAccount.id,
            }
        ),
    )
    for grant in client.service_accounts.project_access.list("proj_1").auto_paging_iter():
        print(grant.serviceAccount.name, grant.rung)
    # Remove releases held Tasks one page per call: repeat under a new key until all are released.
    attempt = 1
    while True:
        removed = client.service_accounts.project_access.remove(
            "proj_1",
            ProjectServiceAccountsRemoveRequest.model_validate(
                {
                    "idempotencyKey": f"remove-nightly-export-proj_1-{attempt}",
                    "serviceAccountId": created.serviceAccount.id,
                }
            ),
        )
        if removed.releasedAllHeldTasks:
            break
        attempt += 1
```

The three calls that mint a secret, `create` with a first token, `tokens.create` and `tokens.replace`, take an idempotency key inside the request model. A retry under the same key answers `replayed` without the secret, because the secret was shown once on the first answer. If you lost that answer, replace the token. A refusal carries a `reason` that names the rule, such as `token_live_limit_reached` or `service_account_name_taken`.

`project_access.grant` and `project_access.remove` also take an idempotency key, and a retry under it answers the first call's outcome and changes nothing. The async client, `AsyncNitsor`, mirrors every method above under `await`, including `await client.service_accounts.project_access.grant(...)`.

## Agents as callers

An agent that holds a token is a first-class caller. It can ask who it is and when its token ends before it acts, you can stop it in one step, and every event it writes carries its service account's identity.

### Who am I

`GET /api/v1/session` answers who the token acts as, which token it is, what it can do and where. The [session fields](/reference/api#session-and-expiry) are in the API reference. The answer describes only the token that made the request, never another token of the same service account.

An agent can read it once at the start of a run:

```bash theme={"system"}
curl -H "Authorization: Bearer $NITSOR_TOKEN" "$NITSOR_API_URL/api/v1/session"
```

```python theme={"system"}
from datetime import datetime, timedelta, timezone

from nitsor import Nitsor
from nitsor.types import SessionToken

NEEDED = "projects:read"

with Nitsor() as client:
    session = client.session.get()
    print(session.principal.displayName)
    # A field a server older than it leaves out holds an Unset value, not data, so check before reading.
    if isinstance(session.effectivePermissions, list) and NEEDED not in session.effectivePermissions:
        raise SystemExit(f"This token does not hold {NEEDED}.")
    if isinstance(session.token, SessionToken):
        ends = datetime.fromisoformat(session.token.expiresAt.root)
        if ends - datetime.now(timezone.utc) < timedelta(days=1):
            print("This token ends within a day. Ask the owner to roll it.")
```

Reading the answer first replaces learning the limits from refusals. The reach is paged: pass `limit` and `cursor` and keep following `nextCursor` until it is null, because a page can hold fewer projects than `limit`, or none, while `nextCursor` is set. Fields that a server older than them leaves out are absent from the answer.

### Expiry warnings

In the last 14 days of a token's life, every authenticated response carries a `Nitsor-Token-Expires` header with the instant the token stops working. The Python SDK turns it into one warning: it issues one `NitsorTokenExpiryWarning`, a `UserWarning`, per process per token, the first time it sees the header. Python's warning filters and `-W` flags apply to it.

The warning names the whole days left, rounded down, and where to replace the token, and never the token. It names the token by its last four characters. Pass the token through `NITSOR_TOKEN` or a stored profile and not inline in source: Python prints the source line of a call in the traceback of an uncaught exception, so a token written on that line is printed with it.

An agent that must not run past its token's end can compare `session.token.expiresAt` with the clock, or turn the Python warning into an error with `warnings.simplefilter("error", NitsorTokenExpiryWarning)`, importing the class with `from nitsor import NitsorTokenExpiryWarning`. An owner or admin replaces the token before then with **Roll**, which keeps the old token working for the overlap while the deployment switches.

### When an agent is stopped

**Disable** is the kill switch for one agent. Its next request to the API fails with 401 `AUTH_INVALID_TOKEN` and, for an otherwise live token, the reason `service_account_disabled`, a job stops at its next step, and leases it already holds stop working within one hour, though a download already under way can finish. After **Enable**, nothing restarts on its own. See [Disable, enable and retire](#disable-enable-and-retire).

### When a token is refused

A refused token gets 401 `AUTH_INVALID_TOKEN`, and the Python SDK does not retry it. When Nitsor knows the token, the answer carries a `reason` and the server's own words. The SDK exposes them on the error as `reason`, `remediation` and, for an expired token, `expired_at`. What an agent does depends on the reason. The [API reference](/reference/api#refused-tokens) lists the wire fields.

| `reason` | What happened | What the agent does |
| - | - | - |
| `service_account_disabled` | An admin paused the service account. The token works again if it is enabled. | Back off. Stop starting work, wait, and look again at a growing interval. Do not ask for another token: nothing is wrong with this one. |
| `token_revoked` | A person revoked this token. It never works again. | Alert a person and stop. Retrying cannot help, and the job needs another token. |
| `service_account_retired` | The service account is retired for good, and its tokens with it. | Alert a person and stop. The job needs a token of another service account. |
| `token_expired` | The token's expiry passed. `expiredAt` says when. | Renew. Ask the service account's owner for its current token, or for a new one. An owner or admin adds it with **Add token**, because an expired token cannot be rolled. |
| No `reason` | The cause is undetermined. Nitsor may not know the token (mistyped, truncated or not a service account's), or the server may predate reasons while the token is expired, revoked or its service account is disabled or retired. | Check the configured token and the API origin, then the service account's state on Team settings, then Service accounts. Retrying the same request cannot help. |

An agent that reads `GET /api/v1/session` at the start of a run learns when its token ends before it is refused: `token.expiresAt` is the instant, and the [expiry warning](#expiry-warnings) repeats it in the last 14 days. After a refusal that call is refused too, so for an expired token take the time from `expiredAt`.

```python theme={"system"}
from nitsor import Nitsor
from nitsor.errors import AuthRejectedError

with Nitsor() as client:
    try:
        client.session.get()
    except AuthRejectedError as error:
        if error.code != "AUTH_INVALID_TOKEN":
            raise
        if error.reason == "service_account_disabled":
            # Paused on purpose. Hold the work and look again later, with a growing delay.
            print("The service account is disabled; backing off.")
        elif error.reason == "token_expired":
            print(f"This token stopped working at {error.expired_at or 'an unknown time'}.")
        elif error.reason in ("token_revoked", "service_account_retired"):
            # Neither comes back. Stop and alert a person.
            raise SystemExit(error.remediation) from error
        else:
            # No reason, or one a newer server added. The server's own words say what to do.
            print(error.remediation)
```

## Next step

Read the [Public HTTP API](/reference/api#service-accounts) for the route contract, or [Integrations and public interfaces](/reference/integrations) to see what a new token looks like and how to use it.

<table aria-label="Page details">
  <tbody>
    <tr><th scope="row">Plans</th><td>Team and Enterprise</td></tr>
    <tr><th scope="row">Last verified</th><td>2026-10-21</td></tr>
  </tbody>
</table>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.