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 wasname 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 thedatasetargument 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/adminresponses 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 of1–100is now400, not a silent clamp. Previously?limit=9999returned200with 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 answers400with{"error":{"type":"invalid_request","message":"…"}}, the message naminglimitand the1–100bound, for any out-of-range or non-integer value. Exact wording differs by surface, and a separate top-levelfieldkey is present only on surfaces routed through the sharedhttpx.WriteErrorwriter — most feature-local writers fold the key intomessageinstead (e.g."limit: must be an integer between 1 and 100"). An omittedlimitstill 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}/creditsandPOST /v1/billing/topupscompare the request body's hash on a repeated key: an identical body is a true replay — the original result, plus a newIdempotent-Replayed: trueresponse header (Stripe's convention) — while a different body under the same key now answers409 {"error":{"type":"idempotency_key_reuse",…}}instead of silently returning the first result. - Discoverability. Sending
Idempotency-Keyon any other/v1route now answers400naming the header, instead of being silently accepted and ignored (a retriedPOST /v1/datasetswith the same key used to create two datasets). The OpenAI/Anthropic compatibility routes (/v1/chat/completions,/v1/messages*) are exempt from that400— 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.