API reference

Changelog

Dated, per-endpoint record of consumer-visible API behavior changes.

Every response now carries a Deeplinq-Version header (build revision + date) so you can tell "not yet deployed" from "not supported" when checking whether a fix below has reached the environment you are calling — see Versioning below.

This page lists behavior changes that alter what a request or response looks like on the wire — new fields, new status codes, new headers, changed defaults. It does not repeat internal refactors or test-only changes. Newest first.

Maintenance note: this page is hand-maintained, not generated. Every commit whose docs/architecture.md Decision Changelog entry describes a consumer-visible wire change (new/changed field, status code, header, or default) must add a matching dated entry here in the same commit — mirror the fact for an integrator audience, not the internal rationale. docs/backlog.md §2 tracks the risk of this drifting unenforced and the two fix directions on the table (generate this page from the architecture changelog, or extend the docs-parity check to assert it).

2026-09-10

A task can be started in a project, and dataset.query reads the whole of it

POST /v1/runs and POST /v1/agents/{id}/runs take an optional project_id beside dataset_id, and the two are mutually exclusive — sending both is 400 project_id and dataset_id are mutually exclusive. The project is checked at create with the identity the RUN executes under, which carries no roles, so a project shared only through a role:org-member grant is not usable; unknown, unreadable and malformed all answer 404 project not found, with no way to tell them apart.

The project belongs to the run's thread, not to the run row. It is written on the conversation the run opens, so the run view echoes it as project_id (null when the thread is in no project), a follow-up sent with conversation_id inherits it and need not resend it, and every run a schedule fires carries the schedule's project_id on its conversation. Naming a different project on a follow-up is 400 a follow-up keeps its thread's project; sending dataset_id on a follow-up whose thread is in a project is 400 a follow-up cannot name a dataset: its thread is in a project. A task thread placed in a project is filed there — its conversation appears wherever that project's conversations do.

dataset.query reads that project when the call names no dataset and the run has no dataset_id: the project's own files plus the datasets attached to it, minus any the run cannot read in its own right, so a task reaches no more than a chat in that project does. The table is resolved across the whole scope, and the query is recorded against the dataset the table was found in. Three refusals are new or changed:

  • the no-dataset case is now this task has no project and no dataset; start it in a project, or name a dataset you can read: A, B (it was name a dataset, or start the task with one; …);
  • no table "invoices" in project "Quarterly review"; tables you can read: sales, orders ("Quarterly review"), regions ("Geo") — the project's tables, grouped by dataset, bounded to ten with a trailing . Every dataset named in these sentences is one the dataset argument resolves to that very dataset; one whose name would reach a different dataset you can read is left unnamed rather than offered;
  • table "sales" is in more than one of the project's datasets: "Quarterly review" (files), "Geo"; name the dataset too.

POST /v1/schedules already took project_id, and it now decides what a fired run READS as well as where its thread lives: every fire opens its conversation inside the project, and dataset.query reads that project when the task names no dataset_id. For an agent_run task the id is checked at create with the roleless identity the fired runs execute under; a webhook schedule's keeps the caller's own roles, since nothing reads a project through it. It is not mutually exclusive with the task's dataset_id there — a task carrying both reads the dataset and lives in the project — and it is still fixed at create, since the patch shape does not carry it.

The dataset argument's own description now reads "Omit for the dataset or project the task was started with". Nothing about dataset_id changed.

One word for one thing: a task reads through a dataset, never a "library"

The product retired standalone Libraries on this date — /libraries redirects to Tools and no customer screen mounts it — while datasets survive, and a project owns both files and datasets. The wire says the surviving word everywhere, so nothing names a concept the product no longer has: the field is dataset_id on POST /v1/runs, POST /v1/agents/{id}/runs, the run view and the message schedule shapes; the dataset.query argument is dataset; and every refusal, error field and tool description says dataset. The renamed shape is what shipped — there was never a released library_id to migrate off — but if you read a preview of this surface under the old names, rename them in one pass. Where a person adds tabular data is a project's files or datasets, not a Libraries screen.

A run or a scheduled task names the dataset it reads, and dataset.query reads it

POST /v1/runs, POST /v1/agents/{id}/runs and the message shapes of POST /v1/schedules take an optional dataset_id: the dataset the task reads through. The run view echoes it as dataset_id, null when the run has none, and every run a schedule fires carries the task's. The id is checked at create with the identity the RUN will execute under, which carries no roles, so read access must survive losing them — owning the dataset, a user or team grant on it, owning the project it belongs to, or a user or team grant on that project — and a dataset shared only through a role:org-member grant is not usable as a run's dataset. Unknown, unreadable and malformed ids all answer 404, with no way to tell them apart, and an accepted id comes back as the canonical 36-character lowercase uuid. The field is rejected on the webhook and compatibility schedule shapes, exactly as connection_ids is.

dataset.query is the engine tool that reads it. The grant is automatic in one shape only — a create body with tools ABSENT, on POST /v1/runs or the message + model schedule shape — where the runner is granted what its person can reach and the engine asks that same roleless identity whether the caller can read any dataset at all. A body that names tools is granted exactly those and never re-synced, and POST /v1/agents/{id}/runs uses the named agent's own grants: both bypass the reach gate, so dataset.query must be among the names or the agent's tools to be callable.

The tool takes dataset (an id or an exact name, case-insensitively; the task's own dataset_id when omitted), table (required with sql), exactly one of question and sql, and max_rows — omitted means 2000, which is also the cap, and a value below 1 is refused by argument validation rather than raised. A question is turned into a SELECT, validated, and repaired once; an explicit sql is run as given and never repaired. The model is shown a preview — query_id, table, columns, preview_rows (at most 100, and bounded by bytes), preview_truncated, capture_rows, total_rows (null when the engine could not count them), truncated and usage. The full capture is stored, but no route returns one today, so query_id is not yet something an integrator can read back. Writing the SQL is billed to the organization and counted in the run's credits. A refusal comes back as a tool result, {"error": "..."}, so the model can act on it: no dataset named and none on the task, a dataset the caller cannot read, sql without table, a statement that is not a single SELECT, a generated statement that could not be repaired, the sidecar's thirty-second budget, a dataset with no tables, a table that is not in the dataset, a table name matching two of its documents, and two datasets sharing the name given — that last one answers more than one dataset is called X; name it by id. Repeating the same call in the same place of the same run replays the stored answer. See Name the dataset a run reads and Query a dataset's tables.

2026-09-09

email.search without a query lists the newest mail

query is optional on email.search. Omitted or blank, the tool answers the mailbox's newest messages first with no filter — {"max_results": 1} is "the newest message", on Gmail and Outlook alike. Before, query was required and the most common request had no legal spelling, so a run spent turns probing for one. The tool and argument descriptions now name the closed qualifier set and how to ask for no filter, and a refused query says the same: unsupported qualifier "in:" — supported: … and qualifier "from:" needs a value, e.g. from:ada@example.com. See Email connections.

A run or a scheduled task names the connections it reaches through: connection_ids

POST /v1/runs, POST /v1/agents/{id}/runs and the message shapes of POST /v1/schedules take an optional connection_ids: the caller's own connections the run should reach through when a tool is declared by more than one provider — with Gmail and Outlook both connected, which mailbox email.* reads. Each id must be one of the caller's own active connections; any other is 404. The run view and the agent_run schedule view echo the list. Empty keeps the engine's default (the longest-standing connection, providers in catalog order), so nothing already flowing moves. See Choose which mailbox a run uses.

2026-09-08

Runs form threads: conversation_id on POST /v1/runs, POST /v1/agents/{id}/runs and GET /v1/runs

Both run-creation bodies take an optional conversation_id. The new run joins that conversation instead of opening one of its own, and the earlier runs' messages, tool calls and tool results are its history. The conversation must be the caller's own, in the same organization, and one a run opened: otherwise 404; a plain chat conversation is 400 (conversation_id: is not a run's conversation); a follow-up while a run on the thread is still queued, running or awaiting_approval is 409.

GET /v1/runs accepts conversation_id, filtering the caller's own runs to one thread, newest first and paged like the rest of the listing. The scope is unchanged — owner-only for every role — so another person's thread answers an empty page, and a malformed id is 404 as for schedule_id. See Continue a thread.

2026-08-30

POST /v1/application/orgs creates a tenant for an organization you already have

New route on the application-credential lane, for an external application that runs its own identity provider. It asks for the engine tenant behind an organization that already exists at that provider.

Body is closed and takes exactly three members, {"name","organization_id", "owner_subject"}. organization_id is the organization's id at the caller's own provider, and owner_subject is the sub its administrator's tokens carry. There is deliberately no issuer member; the issuer is resolved from the calling application, because a body-supplied one would bind an organization to a trust anchor the caller chose.

The answer is 200 {"id","name","created"}. It is 200 rather than 201 because the call converges. It is idempotent on (issuer, organization_id), so a replay after a lost response returns the same tenant with created: false, and the loser of two concurrent identical calls converges on the same answer instead of being told the retry is terminal.

One call writes the tenant, the identity binding that resolves that organization's tokens to it, the owner's org-admin in the engine's own role record, and the application's default model grants. The grants matter on the wire, because model access is granted per organization and a tenant without them answers GET /v1/models with an empty list. No starter credit is granted, deliberately; that budget onboards a person and is capped application-wide per signup, so granting it per organization would repeat it for every organization one person creates.

Refusals are 400 naming the missing or unknown field, 403 without an application credential, 412 for a managed application (its users create organizations through POST /v1/orgs) or for an application whose onboarding is not enabled, and 409 when that organization_id is already bound to another application's tenant or to one that is no longer active. The 409 reveals nothing about whose tenant it is.

This closes a gap that had no workaround. POST /v1/onboarding/signups binds one tenant per person, so a second organization for the same owner was 409 forever, and POST /v1/orgs refuses an external application outright. See Bring your own identity provider.

Skills are a new resource family, and enabled ones reach every model call

Ten new operations under one Skills tag. Five are self-scoped on the application credential, GET/POST /v1/application/skills and GET/PUT/DELETE /v1/application/skills/{id}, and carry a strong ETag with conditional writes (missing If-Match is 428, stale is 412). Five are the end user's own view, GET /v1/skills, POST /v1/skills/personal, GET /v1/skills/{kind}/{id}, DELETE /v1/skills/personal/{id}, and PUT /v1/skills/{kind}/{id}/enablement.

A skill is one SKILL.md document in the Anthropic Agent Skills format, instructions-only subset. Frontmatter requires a single-line name (at most 64 characters) and description (at most 1024). The Markdown body must be non-empty and at most 60 KiB, the whole document at most 64 KiB. Other frontmatter keys, allowed-tools included, are stored unchanged and grant nothing.

Enabling is hash-bound. The enablement body names the content_sha256 the caller last read; a stale one answers 409 skill_changed and changes nothing. A catalog PUT that changes the document's content clears every enablement of that skill in the same transaction, so consent is asked again rather than silently inherited; a provenance-only PUT keeps them and still bumps the version and the ETag. At most 8 enabled skills and 64 KiB of combined instructions per user per organization, over which the answer is 409 skill_context_limit.

Enabled skills render into one system block on every chat completion in both dialects, on one-shot agent invocation, and on every durable agent-run turn. Resolution is live, so a disable or a catalog content change affects the very next call. No request field names a skill, and no response, transcript, summary, or audit payload carries skill text or ids.

Chat gains two 503 refusals that happen before any spend

POST /v1/chat/completions and POST /v1/messages can now answer 503 instructions_unavailable or 503 skills_unavailable. Both are raised before any provider call, including the memory search, so a refused request costs nothing.

This is deliberately not the memory sidecar's fail-soft posture. A memory outage degrades an answer; answering without the constraints a person explicitly saved would misrepresent their chat. 503 memory_unavailable on the memory routes is unchanged.

The assembled request order is now fixed and documented as [project instructions][skills][personal instructions][memory][summary][tail][new message], with knowledge chat's platform prompt still ahead of all of it. An agent turn is persona, then skills, then memory, then tail. See Conversations and memory.

/v1/instructions is a new end-user resource, and the memory opt-out gains its read

GET and PUT /v1/instructions hold one response-preference row per (organization, end user). The body is a closed tone (default | concise | formal | explanatory) plus free-form additional_instructions capped at 16 KiB of UTF-8 bytes. GET before anything is saved answers the default representation with updated_at: null. PUT is a full replacement and returns the stored representation. Unknown JSON keys and unknown query parameters are 400.

POST /v1/instructions/preview runs one governed, non-streaming completion demonstrating an unsaved draft, over the fixed prompt What are the best ways to improve my productivity?, on the caller's first permitted concrete model, and answers {prompt, model, answer, usage}. It is billed, guardrailed and audited exactly like a chat call, and persists nothing. With no usable concrete model it answers 422 model_unavailable; the virtual auto is never used.

GET /v1/memory/optout fills in the read the mutation pair lacked. It answers {opted_out, organization_enabled, enabled}, where enabled is organization_enabled && !opted_out, so a client can restore a persisted switch and tell an organization-disabled state apart from a personal one. It mutates and audits nothing, and disabling memory for an organization preserves each person's stored choice.

GET /v1/mcp-servers/catalog is a member-safe read beside the admin registry

Any authenticated organization member can now see what they could attach. The catalog returns the active servers with at least one tool whose effective policy is not deny, in list order, each with id, slug, name, the caller's own attached and connection_id (JSON null when unattached), and its available tools as namespaced name, description (omitted when blank), and allow or require_approval policy. An empty organization answers 200 {"mcp_servers":[]}; an unknown query parameter is 400.

Six keys, and no operator configuration. No URL, active flag, bearer or bearer-presence metadata, creator, timestamps, argument schemas, definition or approved hashes, denied tools, or revoked connections appear here. Availability is computed from the hash-pinned effective policy rather than the stored enum, so a stale approved hash reads as deny here exactly as it executes, and a server with zero available tools is omitted. Attachment is only ever the caller's own, so an org-admin does not see other members' attachments.

The admin surface is byte-for-byte unchanged. GET /v1/mcp-servers keeps its 403 for a non-admin and the item routes keep their masking 404.

A schedule can fire a durable agent run, and every schedule response carries two new fields

agent_run joins webhook as the second closed action_type. Sending agent_id and message on POST /v1/schedules creates the task; endpoint_id and payload are rejected alongside message. The message passes the agent's input guardrail at create. Create answers 404 for an agent the caller cannot see and 409 for one that is disabled or knowledge-backed, before any row exists.

Every schedule response now carries action_type and engine-computed owned_by_caller, and an agent_run schedule also carries its stored message. Raw action_config is never exposed. Use owned_by_caller rather than comparing owner_user_id against an identity you hold, which is not a reliable comparison for an org-admin.

GET /v1/runs accepts schedule_id, filtering the caller's own runs to one task's fire history. Run listing and detail stay owner-only for every role, so schedule-admin authority still grants no run content.

agent_id without message remains the pre-agent_run compatibility shape and stores the exact literal message scheduled trigger. Do not use it for new schedules.

If the agent stops being usable between fires the schedule pauses rather than running without it, with status_reason exactly agent_unavailable for a deleted or disabled agent and exactly agent_has_datasets for a knowledge-backed one. Resume re-checks both facts and answers 409 while the cause remains.

A knowledge-backed agent cannot start a durable run

POST /v1/agents/{id}/runs answers 409 agent knowledge is not supported by durable runs; use invoke when the agent has attached datasets. Retrieval is not performed inside the checkpointed turn loop, so such a run previously executed without the agent's defining capability and said nothing about it.

The refusal lands after visibility and active checks and before every side effect, so no backing conversation, run row, job, audit write, input screening, or model spend happens. One-shot invocation keeps its retrieval path unchanged, and an agent whose attached set is later emptied creates runs exactly as before.

Pause and resume are compare-and-set

POST /v1/schedules/{id}/pause is valid only from active and POST /v1/schedules/{id}/resume only from paused. Anything else is 409, including a second pause and a pause racing the sweep that just completed a one-time schedule. Two concurrent resumes succeed exactly once. Resume's capacity and agent-eligibility checks now run inside the same transaction as the status write rather than as a pre-check.

Re-registering the identity binding an organization already holds is success

POST /v1/admin/orgs/{id}/identity-binding and the external signup saga used to map any uniqueness violation to 409, including re-inserting the exact same row. A signup replayed after a lost response was therefore refused permanently.

The binding now converges on equality only. After an insert conflict the organization's own binding is re-read and answered as success when the organization, provider, issuer, and remote organization all match and the binding is active. A suspended binding, a different remote organization, or a remote organization held by another engine organization is still 409, and nothing is reactivated, repointed, or adopted. A failed re-read keeps the original conflict rather than masking it.

A token is refused when its organization belongs to another application

An org_identity_bindings row names an issuer, a remote organization, and an engine organization independently, and nothing compared the application each belongs to. An operator could bind application A's issuer to an organization whose application is B, and an ordinary A token then read and mutated B's tenant while being governed and billed as A.

Authentication now resolves the organization's application together with the binding and refuses the credential when it differs from the application whose authorization server signed the token, on the machine lane and the end-user lane alike. The caller sees the lane's uniform 401 with the usual opaque body; the engine log records the cause application-scope alongside both application ids and the organization. Creating the pair through the admin binding route is refused too.

Pre-existing rows were deliberately not migrated. An incoherent binding written before this shipped stops authenticating when the engine restarts on the new build, and the fix is a re-bind. Operator detail is in Organizations.

The comparison runs only where both sides name an application, so the single-issuer TENANT_OIDC_* override and the console's own authorization server are unaffected.

2026-08-13

GET /v1/mcp-servers answers 403, not 404, for a non-admin

A non-org-admin calling the MCP server collection route used to get 404 — indistinguishable from the route not existing. It now answers 403 {"error":{"type":"forbidden","message":"org-admin required"}}, matching every other org-admin collection (/v1/webhook-endpoints, /v1/integrations). GET/DELETE /v1/mcp-servers/{id} are unchanged: a non-admin still gets 404 on a single server, by design — item-level existence is deliberately masked.

List endpoints answer in English and reject an out-of-range limit

Two fixes to every paginated /v1 list surface:

  • English messages. GET /v1/admin/orgs, /v1/admin/orgs/{id}/datasets, and the memory-model admin route answered validation failures in French. All engine-owned /v1//v1/admin responses are English now; the admin-console's own /admin/api/* browser surface is unaffected — that is product copy for a French-locale operator UI, not an API contract.
  • ?limit= out of 1100 is now 400, not a silent clamp. Previously ?limit=9999 returned 200 with a page of (at most) 100 rows and no signal that the request was truncated; some endpoints already rejected it, so behavior differed by route. Every list surface now answers 400 with {"error":{"type":"invalid_request","message":"…"}}, the message naming limit and the 1100 bound, for any out-of-range or non-integer value. Exact wording differs by surface, and a separate top-level field key is present only on surfaces routed through the shared httpx.WriteError writer — most feature-local writers fold the key into message instead (e.g. "limit: must be an integer between 1 and 100"). An omitted limit still falls back to the endpoint's own default — only an explicit bad value 400s.

Unknown query parameters are rejected, not silently ignored

All 14 /v1 list endpoints now answer 400 naming the first unrecognized query key instead of silently dropping it — e.g. GET /v1/admin/orgs?external_org_ref=… (the wrong name for ref) used to fall through to the unfiltered listing and return the first org in sort order. Each endpoint's own accepted parameter set is unchanged (they differ by surface); only the field name from your query is echoed back. There is no alias for a mistyped name — the 400 names the real one.

Idempotency-Key is a documented, body-aware contract

  • Body-aware replay detection. POST /v1/admin/orgs/{id}/credits and POST /v1/billing/topups compare the request body's hash on a repeated key: an identical body is a true replay — the original result, plus a new Idempotent-Replayed: true response header (Stripe's convention) — while a different body under the same key now answers 409 {"error":{"type":"idempotency_key_reuse",…}} instead of silently returning the first result.
  • Discoverability. Sending Idempotency-Key on any other /v1 route now answers 400 naming the header, instead of being silently accepted and ignored (a retried POST /v1/datasets with the same key used to create two datasets). The OpenAI/Anthropic compatibility routes (/v1/chat/completions, /v1/messages*) are exempt from that 400 — the header is tolerated there for drop-in SDKs that attach it out of habit, but still not deduplicated.

max_tokens is honored by every connector type

max_tokens on POST /v1/chat/completions previously worked for OpenAI/ Gemini-family connectors but was silently dropped by an ollama-type connector's cap field — no error, just an uncapped response. The engine now projects the caller's cap onto the field each connector type actually reads, so max_tokens is enforced consistently regardless of the underlying provider. See the API surface for per-type notes.

The rejected-parameters list grows from two entries to nine

POST /v1/chat/completions now rejects logprobs, top_logprobs, presence_penalty, frequency_penalty, parallel_tool_calls, seed, and logit_bias with 400 (previously silently ignored) alongside the existing n/best_of refusals — sending any of these changes what you would receive or pay for, and the engine does not implement them. Sending the field's own OpenAI-documented default value (e.g. presence_penalty: 0) still passes, since that is not a real ask. See the full parameter compatibility matrix.

Chat error bodies stop repeating their type as the message

403 model_not_permitted, 402 insufficient_credits, and 402 user_limit_exceeded used to answer with the message field identical to the type field, e.g. {"error":{"type":"insufficient_credits","message":"insufficient_credits"}} — nothing displayable or actionable. They now carry the actual numbers: model_not_permitted names the refused model, insufficient_credits reports balance and required amount, user_limit_exceeded reports the limit and current spend.

2026-08-12

A missing model now answers 400, not 403

POST /v1/chat/completions with no model field used to fall through to the entitlement check and answer 403 model_not_permitted — identical to asking for a real model you are not entitled to. It now answers 400 {"error":{"type":"invalid_request","field":"model","message":"model is required"}} before any authorization check runs. POST /v1/messages was unaffected (it already validated this first). A present-but-unentitled model on either dialect is unchanged and still 403.

One error envelope, with a mandatory type, everywhere on /v1

Every /v1 and /v1/admin error response (except the Anthropic-compatible /v1/messages*, which keeps its own dialect) now answers the same shape: {"error":{"type":…,"message":…}}, with an optional field on a validation failure and type always present. Previously the shape varied by which part of the engine answered — a bare {"error":{"message"}} from the auth middleware, {"error":{"code","message"}} from the admin console, or {"error":{"field","message"}} from the shared body-validation writer, with no type at all on some of those. No existing type value was renamed — this only fills in the gaps, so any code already branching on a known type string is unaffected.

stream_options.include_usage is honored on streamed chat

POST /v1/chat/completions with "stream": true and "stream_options": {"include_usage": true} now emits one extra chat.completion.chunk (choices: [], populated usage) after the finish-reason chunk and before [DONE] — previously stream_options was accepted with 200 and silently dropped, so a streamed call had no way to learn its token cost. Sending stream_options on a non-streaming request now answers 400 (OpenAI itself rejects that combination).

max_completion_tokens is a first-class request field

POST /v1/chat/completions previously decoded only max_tokens; max_completion_tokens — the field current OpenAI SDKs and models emit — reached the engine and vanished with 200, taking the caller's spend cap with it. It is now honored as its own field: either field alone works, both set to the same value are accepted, and differing values answer 400 naming both fields.

chat.completion and streamed chunks carry id/created/model

POST /v1/chat/completions responses (streamed and non-streamed, including RAG and knowledge-agent replies) were missing created; SSE chunk frames were missing id/created/model entirely — a schema violation for any client validating against OpenAI's chat.completion/chat.completion.chunk shape. Every response and every chunk of a stream now carries all three, consistently. model reports the requested model, not necessarily the model that actually served the completion.

Versioning

Every HTTP response — success, error, 404, 401 — carries a Deeplinq-Version header identifying the running build: a short git revision plus its commit date (<revision>+<YYYY-MM-DD>, e.g. 5796f1cb963f+2026-08-13), with a -dirty suffix on a build made from an uncommitted working tree, or dev when no VCS information is available. The engine has no versioned API dialect to report — this header identifies the deployed build, not a wire-format version, so it is the fastest way to check whether an environment has picked up a fix listed on this page.

On this page

2026-09-10A task can be started in a project, and dataset.query reads the whole of itOne word for one thing: a task reads through a dataset, never a "library"A run or a scheduled task names the dataset it reads, and dataset.query reads it2026-09-09email.search without a query lists the newest mailA run or a scheduled task names the connections it reaches through: connection_ids2026-09-08Runs form threads: conversation_id on POST /v1/runs, POST /v1/agents/{id}/runs and GET /v1/runs2026-08-30POST /v1/application/orgs creates a tenant for an organization you already haveSkills are a new resource family, and enabled ones reach every model callChat gains two 503 refusals that happen before any spend/v1/instructions is a new end-user resource, and the memory opt-out gains its readGET /v1/mcp-servers/catalog is a member-safe read beside the admin registryA schedule can fire a durable agent run, and every schedule response carries two new fieldsA knowledge-backed agent cannot start a durable runPause and resume are compare-and-setRe-registering the identity binding an organization already holds is successA token is refused when its organization belongs to another application2026-08-13GET /v1/mcp-servers answers 403, not 404, for a non-adminList endpoints answer in English and reject an out-of-range limitUnknown query parameters are rejected, not silently ignoredIdempotency-Key is a documented, body-aware contractmax_tokens is honored by every connector typeThe rejected-parameters list grows from two entries to nineChat error bodies stop repeating their type as the message2026-08-12A missing model now answers 400, not 403One error envelope, with a mandatory type, everywhere on /v1stream_options.include_usage is honored on streamed chatmax_completion_tokens is a first-class request fieldchat.completion and streamed chunks carry id/created/modelVersioning