Build a widget over a source
Create a source, upload a version, converse a widget into existence, filter, inspect evidence, and keep it current.
A widget is a private, conversationally built interactive app over one named source: charts, tables and KPIs that a person filters and explores, backed by a deterministic analysis that runs with no model call once it exists. This page is the request-by-request path from an empty organization to a published, filterable, refreshable widget. See Widgets and sources for the version, publication and privacy rules this walkthrough relies on, and the generated endpoint reference for every field.
Every route here requires an authenticated end user — there is no organization-wide access to a widget or a source. The one exception is the application credential's own account-closure erasure route, which does not read or return widget or source content; see Account-closure erasure.
Create a source
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sales-costs-source" \
-X POST "$BASE_URL/v1/sources" \
-d '{"name":"Sales and costs"}'{"id": "…", "name": "Sales and costs", "kind": "upload", "archived_at": null, "layout": "single_file", "active_version_id": null, "revision": 1, "created_at": "…"}Idempotency-Key is required on every source and widget write that does
real work — see Idempotency-Key
for the shared replay contract. active_version_id is null until a version
finishes processing and activates.
A source has a layout: single_file (the default; every upload replaces its one document, as before) or collection. Every version lists the document versions it contains in documents.
POST /v1/sources accepts "layout": "collection". Two refusals apply:
403 source_layout_unavailablewhen the deployment has not enabled collections. Creating a collection needsWIDGETS_V2_LEVELof1or more. Lowering the level later stops new collections and nothing else: document operations on an existing collection keep working.409 source_layout_collectionwhen the single-file upload or restore routes are used on a collection. The document routes below answer a single-file source with409 source_layout_single_file.
GET /v1/sources names the layouts this deployment can create in creatable_layouts: ["single_file"], or ["single_file", "collection"] at level 1 or more. Offer the collection choice only when it is listed.
A source's kind stays "upload" for both layouts. Every collection route answers another person's source exactly as a missing one (404 source_not_found).
Collections of spreadsheets
A collection stacks many CSV and Excel files into shared tables, so a widget can read a month of daily files as one table. The routes are all under /v1/sources/{id}/documents:
| Route | What |
|---|---|
POST | Add 1 to 100 files. 202, with the new version |
POST /preview | What an add would do. Nothing is stored. 200 |
GET | The documents of the active version, or of ?version_id=. Paged with cursor |
GET /{document} | One document as the active version holds it |
PUT /{document} | Replace that document with one file. 202 |
DELETE /{document} | Remove that document. 202 |
GET /{document}/versions | The document's history, newest first |
GET /{document}/versions/{documentVersion}/file | The original bytes of one document version |
The write routes need an Idempotency-Key. Add and replace take multipart/form-data with file parts and an optional plan part: a JSON object naming the table each sheet joins ({"files": [{"tables": {"Sheet1": "sales"}}]} for an add, {"tables": {...}} for a replacement; the sheet key is "" for a CSV). Table names start with a letter or _, use letters, digits and _, run to at most 64 characters, may not start with __dl_, and may not be documents in any case. That name is kept for a table of the collection's documents; a file or sheet named documents is suggested documents_2.
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Idempotency-Key: sales-2026-10-01" \
-X POST "$BASE_URL/v1/sources/$SOURCE_ID/documents" \
-F "file=@sales-2026-10-01.csv" \
-F "file=@sales-2026-10-02.csv"One operation, one version
Every add, replace or remove creates exactly one source version. Its operation records the kind (add_documents, replace_document or remove_document), one entry per file with its outcome (added, replaced, already_present or rejected, and pending until it settles) and a summary of the counts. A single-file version has no operation. Each element of documents also carries original_filename and its sheets (the table and row_count of each).
Retrying an add or replace with the same Idempotency-Key while the first request is still writing answers 202 with the queued version; the upload continues if the first caller disconnects. Poll the queued version as usual: a held 202 does not promise success, and the version can still end failed with upload_abandoned if the first writer later fails.
Operations on one source settle one at a time, and a request made while another is pending is 409 source_update_in_progress. A version activates only after every file it introduces has settled. A version ends in one of three states:
ready: at least one file was added or replaced, or a document was removed. The version activates. Files that were rejected are listed with their reasons.unchanged: every file was already present and none was rejected. Nothing activates and no widget refreshes. The summary says how many were already present.failedwith problemfiles_rejected: no file was accepted because at least one was rejected. Each file's reason is inoperation.files[].problem. The previous version stays active.
Any other failure of the whole operation leaves the previous version active too. Poll the version as you do for an upload; clients must read unchanged as a success.
How files stack
A sheet joins the table the plan names. With no plan entry the engine suggests one: a table with the same columns, else the table sharing at least half of them, else a name from the sheet (or from the file name without trailing dates and numbers, for a CSV). Files in one request that share a header join one table, so three daily CSVs make sales, not sales, sales_2 and sales_3. POST /preview returns the same suggestions, whether the table already exists, and which files are already present.
- A column with the same name keeps the table's type.
- A new column is added, empty for earlier files' rows. A column a file lacks is empty for that file's rows.
- Every numeric column in a collection is
decimal, whole numbers included, so a day of whole numbers never rejects the next day's cents. A text column accepts numbers, stored as text. A numeric column refuses a file whose column holds non-empty text. An empty value never conflicts, and a column that is still empty takes its type from the first values it receives. - A type conflict rejects that file only, with its reason (
column_type_conflict), and the rest of the request proceeds. When several files conflict, the earliest in upload order wins. Other per-file reasons areinvalid_source,formula_cache_missing,reserved_column(a header starting__dl_) andinvalid_table. - Table names stay stable across versions.
Duplicates, replacement and removal
A file whose content is identical to a document already in the collection, or to an earlier file in the same request, is skipped and reported as already_present. It never counts toward the document limit.
Removing a document creates a version without it. Earlier versions, and the results and evidence computed from them, still include it, so its history and its original file stay readable after removal. Removing the last document is allowed: the version has no documents and no tables, and widgets over it fail their refresh and keep their last result.
A replacement never removes by accident. The old file leaves only when the new one is accepted. If the replacing file is rejected (failed, files_rejected), matches the document's own content (unchanged) or matches another document's content (already_present, unchanged), the target keeps its file and rows.
Limits
- 1 to 100 files per operation, at most 64 MiB per request and 50 MiB per file.
- At most 500 documents in the resulting version, counted after duplicates. Beyond it:
collection_document_limit. - Before stacking, at most 256 MiB of sheet data per operation once the files are read (a compressed workbook counts at its read size), and at most 96 MiB stacked into one table at once: its new sheets plus the rows it already holds. Beyond either:
source_capacity_exceeded, naming the limit. Split the upload into smaller operations. - After stacking, at most 64 tables, 64 MiB of stored columns, 2,000,000 rows and 256 MiB expanded per version, and at most 500 sheets stacked into one table. Beyond any of them:
source_capacity_exceeded, naming the limit. - One widget execution reads at most 64 tables and 64 MB of columnar data.
A retried request with the same key lands on the same version, however far the first attempt got, and a replay is never refused by the document limit.
Evidence
Each row of a collection table knows the document version it came from. The provenance columns are hidden: they are not in a table's columns, a widget design never sees them, and they are not part of any output. The evidence for a value carries documents: each element has document_id, document_version_id, original_filename and uploaded_at, naming the document versions the evidence page rests on. For a collection these are the ones the page's contributor rows come from, sorted by file name; for a single-file source it is the one document. Evidence for a measure aggregated by group, a sum for example, names its documents through its source rows like any other. Only a page of the aggregate contributor alone (contributor_id set to <group>.<measure>) carries its rows and no documents, because those rows name no document version.
The columns of a contributor page follow one naming rule. A column that comes from a table is named <origin>.<column>, so a table column literally called document_id appears as <origin>.document_id. Unqualified columns are the engine's own: row_identity, input_value, and on a collection document_id and document_version_id.
Upload a version
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Idempotency-Key: sales-costs-v1" \
-X POST "$BASE_URL/v1/sources/$SOURCE_ID/versions" \
-F "file=@sales-and-costs.xlsx"The body is multipart/form-data with exactly one part, named file — a CSV
or an Excel workbook, 1 byte to 50 MiB. A workbook may have several sheets;
each becomes its own queryable table. Browser-style multipart filenames are
decoded, in this route and in the collection document routes: %22, %0D and
%0A (the HTML form encoding) become ", CR and LF; their hex digits match in
either case, so %0d and %0a are decoded too. CR and LF are then refused. Nothing else is decoded, so %25 stays %25. The rule cannot tell an escaped
character from typed text: a filename that itself contains the characters %22,
%0D or %0A is read as the decoded character, and CR and LF are then refused.
Names stored before 2026-10-04 keep %22 as it was. The response is 202, and the version
starts queued:
{"id": "…", "source_id": "…", "sequence": 1, "filename": "sales-and-costs.xlsx", "state": "queued", "tables": [], "problems": []}Poll it:
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
"$BASE_URL/v1/sources/$SOURCE_ID/versions/$VERSION_ID"state moves queued → processing → ready, or failed with problems
naming what was wrong. A ready version's tables lists each table's alias,
its Excel sheet name (null for a CSV), columns with inferred types, and row
count. A version that fails validation never activates — the source's
active_version_id keeps pointing at whatever was active before, if
anything, so a bad upload cannot take a working widget down.
Create a widget
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: q3-widget" \
-X POST "$BASE_URL/v1/widgets" \
-d '{"source_id":"'"$SOURCE_ID"'","name":"Q3 revenue and margin","model":"gpt-5-mini"}'The source must already have an active version, and it starts the widget's one active draft pinned to it:
{
"widget": {"id": "…", "name": "Q3 revenue and margin", "source_id": "…", "revision": 1, "published_revision_id": null, "published_execution_id": null, "active_draft_id": "…", "refresh": null},
"draft": {"id": "…", "widget_id": "…", "revision": 1, "source_version_id": "…", "name": "Q3 revenue and margin", "model": "gpt-5-mini", "messages": {"items": [], "next_cursor": null}, "definition": null, "preview_execution_id": null, "authoring": null, "authoring_job_id": null, "generation": null, "unresolved": []}
}Describe what you want
Authoring is conversational — you send plain language, never a chart
definition. expected_revision is the draft's own optimistic-concurrency
guard, from the draft you just read:
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: q3-msg-1" \
-X POST "$BASE_URL/v1/widgets/$WIDGET_ID/draft/messages" \
-d '{"expected_revision":1,"text":"Show total revenue and revenue by product."}'{"id": "job-…", "state": "queued", "problems": []}Poll the authoring job:
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
"$BASE_URL/v1/widgets/$WIDGET_ID/authoring/$JOB_ID"state moves through queued → running and ends at succeeded,
clarification, failed, cancelled or superseded — superseded when a
newer message overtook it. Authoring never forces the model into a tool call,
so it runs on models that refuse forced tool choice, such as Claude Sonnet
5.5. A model the provider refuses for this request fails the job with
widget_authoring_model_incompatible: choose another model and send the
message again. On succeeded, re-read the draft
(GET /v1/widgets/{id}/draft); its revision has advanced and
preview_execution_id names the result to show.
When the engine needs a business decision
An ordinary lookup needs no decision: many sales per product joined to a
products sheet with one row per product is checked over the whole source
(every sale must match at most one product) and previews directly. Only the
side the join's declared cardinality requires to be unique is checked — the
lookup side of a many_to_one or one_to_many join, both sides of a
one_to_one. A duplicate key on that side — several costs per product joined
against sales by product, for example — comes back as a clarification
instead of a guess. When the design proposes a per-key highest or lowest
reduction, the question offers exactly that rule:
{
"id": "job-…",
"state": "clarification",
"question": {
"id": "question-…",
"scope": "calculation-sha256",
"text": "Confirm the exact proposed keys and measures for combining cost and revenue by product.",
"options": [{"id": "highest_per_key", "label": "Use the highest value of every listed measure for each listed key"}]
}
}Answer it with an ordinary follow-up message that carries decision:
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: q3-msg-2" \
-X POST "$BASE_URL/v1/widgets/$WIDGET_ID/draft/messages" \
-d '{"expected_revision":2,"text":"Use the highest listed values","decision":{"question_id":"question-…","option_id":"highest_per_key"}}'Only the offered options resolve the calculation — a plain-text answer alone is conversation, not authority. When a lookup side repeats its key, the engine first tells the model, which normally proposes the highest or lowest value per key, and you are asked to confirm that rule. A question can legitimately offer no option: when the model still proposes no reduction, the text names the repeating table and key, and the fix is a source with unique keys or a follow-up asking for the highest or lowest value per key. Direct many-to-many joins stay unsupported. A confirmed rule persists across compatible source versions and is rechecked without a model call on every restore, refresh and filter. Later edits of the same widget reuse the rule the draft currently states while the relationship is unchanged; a changed key, measure or operator, or a return to a rule you have since replaced, asks again.
Publish it: Save
Once the draft's preview looks right, publish it. preview_execution_id
comes from the draft you last read:
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: q3-save-1" \
-X POST "$BASE_URL/v1/widgets/$WIDGET_ID/draft/publish" \
-d '{"expected_revision":3,"preview_execution_id":"'"$EXECUTION_ID"'"}'{"id": "revision-…", "widget_id": "…", "sequence": 1, "name": "Q3 revenue and margin", "definition": {"…": "…"}, "accepted_source_version_id": "…", "accepted_execution_id": "…", "created_at": "…"}The draft retires; the widget's published_revision_id and
published_execution_id now point at what you just saved. Editing again
opens a draft with POST /v1/widgets/{id}/draft — the body is required
(send {} at minimum; a literally empty body is 400). With no open draft,
which is the state right after Save, this creates a new one from the
published design, pinned to the source's CURRENT active version, and runs
the design there immediately — so the response already carries a fresh
preview (or a failure, if the design no longer fits that version). Sending
{"from_revision_id":"…"} instead restores an older saved design the same
way (see Restore).
Filter it
A published (or previewed) result can be filtered against the whole pinned source, not just whatever rows the preview captured:
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: q3-filter-1" \
-X POST "$BASE_URL/v1/widgets/$WIDGET_ID/views" \
-d '{"base_execution_id":"'"$EXECUTION_ID"'","parameters":{"region":{"type":"text","value":"EMEA"}}}'{"id": "execution-…", "state": "queued", "problems": [], "outputs": [], "parameter_definitions": [...], "applied_parameters": {"region": {"type": "text", "value": "EMEA"}}, "pins": {...}, "definition_version": 1, "created_at": "…"}Each value is a typed cell exactly as parameter_definitions declares it —
{"type":"null"} selects the rows where the column has no value — and a
parameter with "multiple": true takes a list of cells. What you leave out,
and what you send as null, differ:
In parameters | Means |
|---|---|
| the key is absent | the parameter's saved default applies (no filter when the default is null) |
null | no filter on this parameter, even over a saved default |
[] (multiple only) | matches no row: the result is empty |
So clearing one filter is null for a parameter with a default and simply
omitting a parameter without one, and resetting every filter is the saved
result itself — the published execution needs no new view.
Poll GET /v1/widgets/{id}/executions/{execution} until state is
succeeded; each outputs[] entry carries an output_id. Only a table
output pages — GET .../executions/{execution}/outputs/{output}?cursor=, 100
rows a page. A chart output never pages: its whole result rides in the
execution response, and it must fit in 1,000 rows or the WHOLE execution is
refused with chart_too_large (a fixed message; it does not name which
output) rather than truncated — so a chart's design has to group or narrow
the data to fit, not rely on the viewer paging through it. A filter never
asks a model anything; it re-runs the saved analysis with new parameter
values.
To offer a searchable picker for a filter's own values:
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
"$BASE_URL/v1/widgets/$WIDGET_ID/executions/$EXECUTION_ID/parameters/region/options?search=EM"Choices are the distinct values across the whole source, not the current
result — 100 per page, with availability/reason explaining a scope the
engine cannot resolve.
Inspect a displayed value
Click-through evidence explains one specific cell:
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/widgets/$WIDGET_ID/executions/$EXECUTION_ID/evidence" \
-d '{"output_id":"totals","selection_token":"'"$SELECTION_TOKEN"'"}'selection_token comes from that exact result's selection_tokens, alongside
the rows in GET .../outputs/{output}. The response carries the formula in
plain language, its intermediate inputs, and the paginated contributing
source rows — for (Revenue − Cost) / Revenue, revenue and cost are broken
out independently rather than merged into one list. Evidence is pinned to
the execution and parameters that produced the value, so it stays correct
even after a newer filter or refresh has moved on.
Keep it current
Uploading a new version that activates
(POST /v1/sources/{id}/versions) fires an independent refresh for every
published widget pinned to that source — no request from you required. Watch
the widget's refresh field (a job) for the outcome, or retry one
explicitly:
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Idempotency-Key: q3-refresh-retry-1" \
-X POST "$BASE_URL/v1/widgets/$WIDGET_ID/refresh"A refresh that fails — a sheet the design reads is gone, a join assumption the new data breaks — leaves the previous published result and its evidence exactly as they were; nothing renders a half-updated dashboard.
Repair a broken widget
- Look at the widget's
refresh(or the draft'sunresolved) for why. - Open a draft:
POST /v1/widgets/{id}/draft, body{}. Two starting points, and the response tells you which one you got:- No draft was already open (the ordinary case right after a refresh failure) — this creates a NEW draft from the published design, pinned to the source's current active version, and runs the design there at once. If that's the same incompatible version the refresh already failed on, the response already carries the same failure — you don't need a separate step to discover it.
- A draft was already open (you were mid-edit when the failure
happened) — this resumes it unchanged, still pinned to whatever version
it had. To move THAT draft onto the source's current active version
without losing your in-progress edits, send
POST /v1/widgets/{id}/draft/update-sourceinstead ({"expected_revision":<the DRAFT's own revision>,"source_version_id":"<the source's active_version_id>"}— not the widget'srevision). It re-pins the draft and re-runs the design synchronously; a design that still fails on the new version is recorded on the draft as its failure.
- If the design itself needs to change, send a new draft message describing the fix (or resolve a fresh clarification the way you did above).
- Save again. Nothing above republishes on its own — the widget stays on its last good result until you do.
A structural or semantic failure also clears on its own the next time a compatible source version activates (or a source restore reactivates one) — the widget's independent refresh just succeeds — so repair is not always a manual step.
Restore a source version or a design revision
Two independent histories:
# A new active source version, built from an earlier version's content:
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: q3-restore-src-1" \
-X POST "$BASE_URL/v1/sources/$SOURCE_ID/versions/$OLD_VERSION_ID/restore" \
-d '{"expected_source_revision":3}'
# A draft carrying an earlier saved design, pinned to the source's CURRENT
# active version:
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: q3-restore-design-1" \
-X POST "$BASE_URL/v1/widgets/$WIDGET_ID/draft" \
-d '{"from_revision_id":"'"$REVISION_ID"'"}'Restoring a design does not publish it — it opens a draft you still Save,
and it runs that design immediately the way resuming does. A draft already
open from a different origin answers 409 widget_draft_exists; discard it
first — DELETE /v1/widgets/{id}/draft with a required
{"expected_revision":<the draft's own revision>} body — if you want to
restore a different revision instead. List saved revisions with
GET /v1/widgets/{id}/revisions and read one with
GET /v1/widgets/{id}/revisions/{revision}.
Delete
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-X DELETE "$BASE_URL/v1/widgets/$WIDGET_ID" \
-d '{"expected_revision":4}'Deleting a widget retires its content and references and leaves a non-readable tombstone so a late job cannot resurrect it.
DELETE /v1/sources/{id}/versions/{version} refuses a version that is the
source's current active version, still processing, or still being restored
from — and, independent of all three, one that is REFERENCED. A reference
is left by an open draft, a saved widget revision, and every execution the
version was read for — a preview, a filtered view, or a refresh (automatic
or POST /v1/widgets/{id}/refresh). References are released, not
permanent: moving the draft with update-source releases the draft's own
OLD reference (its previews on the old version keep theirs); Save releases
the draft's reference (the saved revision holds its own, separately) but
keeps every preview that draft made; Discard releases the draft's
reference and those of its previews that no saved revision uses — and
discarding the draft of a widget that was never saved deletes the widget
itself, releasing everything it held. Revisions, views, refreshes and the
previews of every saved draft stay referenced until the widget itself is
deleted, so every version a published widget was ever refreshed onto, or a
saved draft ever previewed on, stays referenced for the widget's whole
lifetime — deleting the widget then releases all of it at once. A version
that has newly activated but that a published widget has not yet
refreshed onto also refuses deletion, for the same reason: the pending
refresh still owns it. Every one of these is 409 source_version_referenced
(or 409 source_update_in_progress while still processing). DELETE /v1/sources/{id} refuses the same way if the source has ANY version still
processing or referenced. Archive instead of deleting a source you want to
retire but that still has referenced history:
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-X PATCH "$BASE_URL/v1/sources/$SOURCE_ID" \
-d '{"expected_revision":'"$SOURCE_REVISION"',"archived":true}'expected_revision is the source's current revision — read it fresh first
(GET /v1/sources/{id}); it advances on every activation, so a number from
earlier in a session is likely stale. Archiving is irreversible — a
later {"archived":false} is refused — and it stops new uploads and restores
on that source (409 source_archived). It does not, by itself, stop a new
widget from being created against the source's still-active version; a
consuming application that wants that has to enforce it in its own picker.
Who can read what
Every resource in this walkthrough — the source, its versions and files, the widget, its draft and conversation, every revision, execution and piece of evidence — is private to the person who created it, in the organization it was made in. See Privacy for exactly what that means, including for an organization administrator.