Skip to main content
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 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 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.
  • 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 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 and search_rule_too_complex. They are the first handler-raised 422s 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.
  • 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 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. 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.