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

# Changelog

> Every change to the ish API wire, newest first, with what each one asks you to change.

Every change to the API's wire contract, newest first. Entries are append-only: an entry is never edited after the fact, so what you read here is what shipped on that date.

`/api/v1` is in developer preview, so breaking revisions still land on the current version rather than opening a new one. See [versioning](/api/versioning) for the rules that apply, and read this page before each upgrade.

## 2026-08-02 (spec 1.5.0)

Additive. No existing operation changed.

* **Usage is readable from a key**, under a new `usage:read` scope that nothing implies and the mint default does not include. Three operations: the session usage series over time, the spend cap with credits drawn against it, and the rate-limit budgets as an on-demand read (answered from the counter without charging it).
* **The series can be split per environment or per session status, and deliberately not per API key.** A key cannot enumerate its siblings; that split stays with a signed-in member in the app.
* **Window percentiles are computed over the window, not averaged from the buckets**, because percentiles do not compose. A per-bucket `latency_p95_ms` is `null` below 20 turns rather than reported, since under that it sits within a hair of the slowest single turn. `turns` sits beside it so "not enough data" and "no data" stay distinguishable.
* **A window too wide to represent is refused, not silently shortened.** Previously a long enough daily range returned a truncated series while still echoing the full date range back, so the totals beside it belonged to a shorter window. It now returns `422` naming the limit.
* **Filler buckets carry zeros and `null` latency, never `0` ms.** The last bucket in a series is usually partial, so divide by elapsed time rather than by a whole day.

## 2026-08-02 (spec 1.4.0)

Additive. No existing operation, status code or body changed.

* **Every response now tells you what budget it just spent.** Requests made with an API key carry `x-ratelimit-limit`, `x-ratelimit-remaining` and `x-ratelimit-reset`, so you can pace off `remaining` instead of discovering the ceiling by tripping it. The `429` carries them too, beside `Retry-After`.
* **`x-ratelimit-reset` is seconds from now, not a Unix timestamp.** It is measured when the response is written, so it decays across successive calls. Read it as a delay, never as a clock reading.
* **The headers are optional and their absence is meaningful.** A request authenticated with a user access token carries none, because those are deliberately unlimited. A response refused before the limiter runs, such as a `401`, carries none either. Treat a missing header as "no budget was charged", not as zero remaining.
* **One request can spend two budgets** (a session create spends both the per-request limit and the daily session quota). There is one set of header names, so the triple always describes whichever budget is closest to refusing you.
* **A deployment that cannot count honestly says nothing.** If the rate-limit counters are not shared across servers, the headers are omitted rather than published with a number that describes one server instead of your actual budget. Enforcement is unaffected either way.

## 2026-08-01 (spec 1.3.0)

Additive. Nothing existing changes shape, and no operation refuses anything it previously accepted.

* **You can now manage your own people, not just read them.** Create, update, archive and restore, under a new `people:write` scope: `POST`, `PATCH` and `DELETE` on `/v1/workspaces/{workspace_id}/people`, plus `POST .../people/{person_id}/restore`. `people:write` does **not** imply `people:read`: the two are granted independently, so a key that only feeds people in never gains the ability to read the pool back out.
* **Delete archives; it does not erase.** `DELETE` sets `archived_at` and the row stops appearing in listings unless you pass `?archived=true`. Restore brings it back. Sessions that already ran keep the person snapshot they froze at open, byte for byte, across both. Permanent erasure stays inside the ish app.
* **The write contract is narrower than the read shape.** `product_id`, `type`, `visibility`, `custom_fields` and the simulation config are not writable through this API: a key cannot move a person between workspaces or change what kind of thing it is. Send a field explicitly as `null` to clear it; omit it to leave it alone.
* **`external_id` for your own identifiers.** Workspace-unique, and looked up with `?external_id=` rather than in the path, so your id space never has to collide with ours. Create accepts `Idempotency-Key`.
* **Generation is an API operation, and it is metered.** `POST .../people/generation-jobs` with `GET` polling and `POST .../cancel`. The job reports a live `counts` object as people land. Billing draws **2 credits per person that actually succeeds**, plus **1 more when avatars are generated**. The number you watched climb is the number you are charged for. Cancelling keeps whatever was already produced and you pay only for that. `Prefer: wait=n` (1-60s) holds the create open for a short job rather than making you poll.
* **An unknown interest id is now a `422` instead of an empty page.** A `qualification`/`tag` filter naming an id outside the taxonomy used to expand to itself and match nothing, so a typo returned a clean, well-formed, empty result indistinguishable from a real audience of zero. It now names the offending id.

## 2026-07-28 (spec 1.2.0)

Mostly additive. One existing operation changes what it refuses, and only for a `person_id` no integration should have been sending.

**Fixed**

* **A session can no longer be opened for a real person.** `POST /v1/workspaces/{workspace_id}/sessions` now answers `404` `person_not_found` when `person_id` names a real person who participates in ish under consent, rather than opening the session. This lane has always been documented as simulated people only, but the check tested ownership and library membership without testing which kind of person the record was, so some real-person records satisfied it. The answer is `404`, identical to an id that does not exist, rather than the `403` `person_out_of_scope` used for another workspace's persona: that `403` confirms a person exists and says they are not yours, which is the wrong thing to say about a human being.

  If this changes an answer you were getting, the `person_id` was reaching a record it should never have reached. Every simulated person is unaffected, and [`GET .../people`](/api/reference/people/list-people) lists exactly what a session create accepts.

**Additive**

* **You can read the people you run sessions for.** Five new operations under a new `people:read` scope: `listPeople`, `searchPeople`, `getPerson`, `getPeopleCounts`, and `getPeopleSchema`, all under `/v1/workspaces/{workspace_id}/people`. Until now a `person_id` had to come out of the ish app and be pasted into your code; an integration can now find its own participants. See [people](/api/concepts/people).
* **`people:read` is not in the mint default.** A person record carries demographics and a written background, so the scope is granted deliberately rather than by default. Existing keys gain nothing: scopes are fixed at mint, so mint a new key, or rotate, to pick it up.
* **Ownership is an `owner` alias, not a visibility enum.** Every person carries `owner`: `self` for one your workspace owns, `platform` for one from the shared ish library. Both are runnable, and the two together are exactly what a session create accepts, which is why the unfiltered list returns the union. That equality holds in both directions as of the fix above: the two surfaces now apply the same rules, so a person the list omits is a person a session create refuses. `workspace_id` is present only on `self` rows. People that other workspaces have published are deliberately absent: they are discoverable in the app but not runnable through the API, and listing ids you cannot use would be worse than omitting them.
* **Search takes the same rule tree the ish app builds.** `POST .../people/search` accepts a boolean tree of attribute filters, capped at 2 levels of nesting, 15 conditions per group, and 25 in total. Saved segments are not exposed as objects, because a segment is a rule: send its rule. See [search grammar](/api/guides/people-search) for the semantics and a worked example.
* **`GET .../people/schema` publishes the attribute vocabulary.** Every attribute with its type, its allowed values, whether it is filterable and under which key, and a `writability` block. It is authoritative: a rule naming an attribute the schema does not publish is refused rather than quietly matching nothing. Cache it, since it is the same for every workspace.
* **Two new error kinds, both `422`:** [`search_rule_invalid`](/api/errors#search_rule_invalid) and [`search_rule_too_complex`](/api/errors#search_rule_too_complex). They are the first handler-raised `422`s to carry an `error_kind`, so a `422` is no longer safe to treat as "malformed body" without reading the envelope.

**What a person does not carry**

Worth stating plainly if you are comparing against what the ish app shows. A person on this API carries identity, demographics, interests, and an accessibility profile. It carries no moderation history, no publishing attribution, no workspace-defined custom fields, and no files. `type` is always `ai`: real people who participate in ish under consent are not reachable with any key you can mint.

## 2026-07-28 (spec 1.1.0)

A breaking pre-release revision that renames the intent noun. Everything below this entry shipped under spec `1.0.0`; from here on each entry names the `info.version` of the OpenAPI spec it shipped in, so you can correlate a spec diff with the entry that explains it.

**Breaking**

* **The intent primitive is a `task`, not an `assignment`.** `assignment` now names only the act of assigning a task to someone, which is not something the API exposes, so the word has left the public surface entirely.

  | Was                                                                                                       | Now                                                                               |
  | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
  | `POST/GET /v1/workspaces/{workspace_id}/assignments`                                                      | `.../tasks`                                                                       |
  | `GET/PATCH/DELETE /v1/assignments/{assignment_id}`                                                        | `/v1/tasks/{task_id}`                                                             |
  | `assignment: {goal}` or `assignment: {id}` on session create                                              | `task: {instructions}` or `task: {id}`                                            |
  | `assignment` object on a session read                                                                     | `task`, with `instructions` in place of `goal`                                    |
  | `goal` on the registry (create, update, read)                                                             | `instructions`                                                                    |
  | `assignment_id` filter on `listSessions`                                                                  | `task_id`                                                                         |
  | `assignment_name_taken`, `assignment_archived`, `assignment_not_found`, `assignment_in_published_listing` | `task_name_taken`, `task_archived`, `task_not_found`, `task_in_published_listing` |

* **`source_assignment_id` is gone from the task response.** It was a lineage pointer nothing ever wrote, so it served `null` on every row for every caller. Removed rather than kept as a permanent null, because a field you can never receive a value in is one you should not write a branch for.

**Additive**

* **API keys now reach the task registry.** All five task operations previously refused keys outright with a `403` `api_key_not_permitted` and could only be called with a user access token. They now take the same scope pair every other registry uses: `tasks:read` for list and read, `tasks:write` for register, update, and delete. An under-scoped key gets `403` `insufficient_scope` with `detail.required_scope`.
* **`tasks:read` joins the mint default.** A newly minted key carries `sessions:run`, `sessions:read`, and `tasks:read`, so a key that runs sessions can also resolve a task it binds by id. **Keys minted before this date are not changed.** Scopes are fixed at mint, so an existing key gains nothing: mint a new key, or rotate, to pick up the default. No `:write` scope is granted by default.
* **Session list rows carry `participant.type`.** It distinguishes a simulated participant from a real one. Present on `listSessions` rows; absent on older rows, which carry no type.

**Changed wording, same behavior**

* The `409` refused when you retire a task an active recruitment listing still references now says so in those terms. `detail.error_kind` stays `task_in_published_listing` and `detail.listing_id` still names the listing.
* Task name uniqueness is described as per workspace, which is what it means for an API caller. The rule itself did not change.
* `background` and `steps` on a task are documented as accepted and stored but not yet consumed by API sessions. They always behaved this way; only the description was unclear.
* No documented operation returns `api_key_not_permitted` any more. The kind stays in the catalog because it still applies elsewhere in the product.

**What to change in your client**

1. Repoint the four registry paths from `/assignments` to `/tasks`.
2. Rename the session-create `assignment` block to `task`, and `goal` to `instructions` inside it. A leftover `assignment` or a top-level `goal` is a `422` naming the stray key, never a silent partial accept.
3. Read `task` (with `task.instructions`) where you read `assignment` (with `assignment.goal`) on session responses.
4. Switch any `assignment_id` filter on `listSessions` to `task_id`, and branch on the four renamed `task_*` error kinds.
5. Drop `source_assignment_id` from your task model.

## 2026-07-28

A breaking pre-release revision. It renames the public actor noun, renames one environment field, moves the close endpoint, and widens what create and close return. If you integrated before this date, three edits are required.

**Breaking**

| Change                                      | Was                                | Now                                                    |
| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------ |
| The actor field on create                   | `persona_id`                       | `person_id`                                            |
| The actor field on a session read           | `persona_id`                       | `person_id`, and `participant.person_id` on a list row |
| The environment descriptor's platform field | `{ "name": ..., "platform": ... }` | `{ "name": ..., "kind": ... }`                         |
| Closing a session                           | `DELETE /v1/sessions/{session_id}` | `POST /v1/sessions/{session_id}/close`                 |

The old `DELETE /v1/sessions/{session_id}` is gone rather than deprecated, and a request to it is a `405`. The new close takes no request body and requires the same `sessions:run` scope.

**Additive**

* **Create and close return the full session.** Both used to return a five-field stub (`id`, `status`, `decision_mode`, `turn_count`, `max_turns`). They now return the same 23-field object a read returns, including the frozen `assignment`, the frozen `environment` and its four `environment_*` pointers, `person_snapshot`, `accrued_credits`, `ended_reason`, `ended_at`, and the timestamps. Nothing was removed, so a client reading only the original five fields keeps working.
* **`listSessions` gained two filters**: `environment_version_id` pins the list to sessions run against one declared environment version, and `search` matches a session id by prefix or the frozen goal case-insensitively. The existing `status`, `api_key_id`, `environment_id`, `assignment_id`, `created_from`, and `created_to` filters are unchanged.
* **`Idempotency-Key` now covers every create.** It was accepted on create-session only; it is now accepted on create-environment, create-environment-version, and create-assignment on the same terms. A retry with the same key and body replays the existing row; the same key with a different body is a `409` `idempotency_conflict`.
* **Observation retention is now a session and environment setting.** `observation_retention` (`none`, `30d`, `90d`, or `indefinite`) can be set on an environment as its default and overridden per session on create. It is served back on the session. An environment described inline with no override resolves to `none`.
* **Turns can carry the frame back.** A turn in the trace now carries `observation_url`, a short-lived signed URL for the retained frame, plus `observation_width` and `observation_height`. `observation_url` is null when nothing was retained, which is what a `none` session always reports. Read the URL, do not store it.
* **List rows carry `ended_at`.** `ended_at` moved onto the shared session base, so a `listSessions` row now serves it alongside `settled_at` without a second read.
* **Every error kind is now documented and stable.** Responses across all four surfaces carry a machine-readable `detail.error_kind`, and the full catalog of 33 public kinds is published at [errors](/api/errors). Branch on the kind rather than the status: several kinds share a status and want different remedies.
* **The registry list envelopes reserve pagination.** The environment, environment-version, and assignment lists now carry `has_more` and `next_cursor` alongside `items`. Both are inert today (`false` and `null`) because those lists return the whole collection, and they are present so pagination can land later without a wire break. Read them rather than assuming a single page.

**What to change in your client**

1. Rename `persona_id` to `person_id` in your create body, and read `person_id` (or `participant.person_id` on list rows) where you read `persona_id`.
2. Rename `platform` to `kind` in any inline `environment` descriptor.
3. Change your close call from `DELETE /v1/sessions/{id}` to `POST /v1/sessions/{id}/close`.

## 2026-07-27

A breaking pre-release revision to how a session carries its intent.

**Breaking**

* Session create bodies carry the intent as a required `assignment` object with exactly one shape: `{"goal": ...}` describes it inline, or `{"id": ...}` binds a registered assignment and freezes its goal and revision onto the session.
* A top-level `goal` field is rejected with a `422`.
* Session reads return an `assignment` object in place of the flat `goal`.
* `Idempotency-Key` values stored before this revision no longer replay. A retried old-shape body is a `422`, and reusing an old key with a new-shape body is a `409` `idempotency_conflict`. Mint fresh keys after migrating.
