Guides

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_unavailable when the deployment has not enabled collections. Creating a collection needs WIDGETS_V2_LEVEL of 1 or more. Lowering the level later stops new collections and nothing else: document operations on an existing collection keep working.
  • 409 source_layout_collection when the single-file upload or restore routes are used on a collection. The document routes below answer a single-file source with 409 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:

RouteWhat
POSTAdd 1 to 100 files. 202, with the new version
POST /previewWhat an add would do. Nothing is stored. 200
GETThe 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}/versionsThe document's history, newest first
GET /{document}/versions/{documentVersion}/fileThe 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.
  • failed with problem files_rejected: no file was accepted because at least one was rejected. Each file's reason is in operation.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 are invalid_source, formula_cache_missing, reserved_column (a header starting __dl_) and invalid_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 parametersMeans
the key is absentthe parameter's saved default applies (no filter when the default is null)
nullno 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

  1. Look at the widget's refresh (or the draft's unresolved) for why.
  2. 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-source instead ({"expected_revision":<the DRAFT's own revision>,"source_version_id":"<the source's active_version_id>"} — not the widget's revision). 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.
  3. 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).
  4. 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.

On this page