Guides

Agents and durable runs

Define governed agents, attach knowledge and tools, and execute bounded workflows.

Agents package a persona, model policy, optional knowledge, and tool grants. Use one-shot invocation for a single governed completion or create a durable run for multi-turn tool execution, approval, retry, and checkpointing.

Create an agent

curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/v1/agents" -d '{
  "owner_type":"user",
  "owner_id":"user-42",
  "name":"Mailbox triage",
  "description":"Find and summarize billing email.",
  "persona":"Be concise. Never send email without explicit approval.",
  "default_model":"gpt-5-mini",
  "allowed_models":["gpt-5-mini"],
  "temperature":0.2,
  "tools":[
    {"tool_name":"email.search","policy":"auto"},
    {"tool_name":"email.send","policy":"requires_approval"}
  ]
}'

Agents can belong to a user, team, or org. Creation and management authority follows that owner.

Tool policy is either:

  • auto: the durable run may execute it;
  • requires_approval: the run parks before execution.

policy is optional. Omit it and the default follows the tool's origin: first-party tools default to auto, while tools from a registered MCP server (mcp/<slug>/<tool>) default to requires_approval — that server is operated by a third party and can change what a granted tool name does. Running one unattended is possible, but you have to say "policy":"auto".

Agents never store provider or connection credentials. Tools resolve from the invoking owner's current connections.

Attach knowledge or tools

Replace the attached dataset set:

PUT /v1/agents/{id}/datasets
{"dataset_ids":["<dataset-id>"]}

Attached datasets are caller-filtered at invocation time. The response reports attachments the caller cannot access rather than using agent ownership as a confused deputy.

Tool grants and datasets are mutually exclusive in the current one-shot retrieval design.

An agent with attached datasets cannot start a durable run. Retrieval is not performed inside the checkpointed turn loop, so a run would have executed without the agent's defining capability. POST /v1/agents/{id}/runs now answers 409 agent knowledge is not supported by durable runs; use invoke before any side effect. No backing conversation, no run row, no job, no audit write, no input screening, and no model spend happens. Use one-shot invocation, which keeps its retrieval path unchanged, or replace the attached set with an empty one to make the agent runnable again.

One-shot invocation

curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/v1/agents/$AGENT_ID/invoke" -d '{
  "message":"Find invoices received this week.",
  "model":"gpt-5-mini"
}'

One-shot invocation can return tool calls but does not execute requires_approval tools server-side. Use a durable run for managed execution.

Start a durable run

curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/v1/agents/$AGENT_ID/runs" -d '{
  "message":"Find the latest invoice and draft a reply asking for line-item detail.",
  "max_turns":8,
  "credit_budget":500000
}'

The response identifies an asynchronous run. Poll:

GET /v1/runs/{run_id}
GET /v1/runs?agent_id={agent_id}&status=running
GET /v1/runs?schedule_id={schedule_id}
GET /v1/runs?conversation_id={conversation_id}

schedule_id filters to the fire history of one scheduled agent task; conversation_id filters to one thread of runs. Run listing and run detail stay owner-only for every role, so an org-admin who may administer a schedule still does not see its run content, and another person's thread lists as an empty page rather than a refusal.

Each turn holds a database-time lease, performs model or tool work, and commits a durable checkpoint. Restarts and replica changes do not discard committed progress.

Choose which mailbox a run uses

The email.* tools are declared by both mail providers. When the caller has Gmail and Outlook connected, the engine's default serves a call through the longest-standing connection with providers in catalog order — Gmail. To have a run read another mailbox, name the connection on either create route:

{
  "message": "Read my flight itinerary and add the flight to my calendar.",
  "model": "gpt-5-mini",
  "connection_ids": ["<outlook-connection-id>"]
}

Each id must be one of the caller's own active connections (GET /v1/connections); any other is 404. The run view echoes the list as connection_ids, and a preferred connection that is no longer active falls back to the default rather than failing the call. An empty list changes nothing.

Name the dataset or the project a run reads

dataset_id names the dataset the task reads through. It is optional on both create routes, and the run view echoes it as dataset_id, null when the run has none:

{
  "message": "How many orders shipped late last quarter?",
  "model": "gpt-5-mini",
  "dataset_id": "<dataset-id>"
}

The engine checks the id at create with the identity the RUN will execute under, and that identity carries no roles. Read access that survives losing the roles is: owning the dataset, a direct user grant on it, a team grant on it, owning the project the dataset belongs to, or a user or team grant on that project. A dataset shared only through a role — the ownerless organization dataset with a role:org-member read grant — is therefore not usable as a run's dataset, and is refused exactly like a dataset that does not exist. Unknown, unreadable and malformed ids all answer 404, with no way to tell them apart. The id is stored in canonical form, so any accepted spelling comes back as the canonical 36-character lowercase uuid.

project_id names a project instead, and a task started in one reads the whole of it — the project's own files plus the datasets attached to it:

{
  "message": "How many orders shipped late last quarter?",
  "model": "gpt-5-mini",
  "project_id": "<project-id>"
}

The two fields are mutually exclusive: sending both is 400 project_id and dataset_id are mutually exclusive. A project is a set of datasets, so naming one of each says two different things about what the run reads. The project is checked exactly as the dataset is — at create, with the roleless identity the run executes under, so a project shared only through a role:org-member grant is not usable — and unknown, unreadable and malformed all answer 404 project not found.

Unlike dataset_id, the project belongs to the run's thread rather than 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 run (conversation_id) inherits the thread's project and need not send anything. Naming a different one is 400 a follow-up keeps its thread's project, and 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 schedule's project_id is written on every fired run's conversation, so every run of a scheduled task reads the same project.

Placing a task in a project also files its thread there: the conversation appears wherever that project's conversations do.

Query a dataset's tables

dataset.query is an engine tool: the engine executes it, not a connection and not the runner. It answers a question about the tabular files in one dataset — CSV files and Excel sheets, one table per file or sheet — from a question in plain words, which the engine turns into a read-only DuckDB SELECT, or from a statement you write yourself. One query reads one table; a run started with project_id may reach any table in the project — across at most ten of its datasets, the same ceiling a project's scope carries everywhere else — and the engine records which dataset the table was found in.

A run is granted the engine's tools automatically in exactly one shape: a create body that omits toolsPOST /v1/runs, and the message + model schedule shape. That runner is granted what its person can reach, and the engine asks the same roleless identity the run executes under whether the caller can read at least one dataset, or failing that at least one project. A caller who can read neither is not offered dataset.query — or ui.surface, which needs a query and therefore a dataset just as much — rather than being offered tools whose every call they would have to refuse. Both engine tools only read, so the reach grants them at auto. POST /v1/runs re-takes that reach on every request; a schedule takes it once, when the schedule is created.

Both other shapes bypass the reach gate, and in both you must ask for each tool by name:

  • tools present. The runner is granted exactly the names you sent, at the catalog's default policy, and is never re-synced. Include dataset.query among them — and ui.surface too if the task should answer with an interface — or the run cannot call it; an explicit [] is a runner with no tools at all.
  • POST /v1/agents/{id}/runs. The named agent's own grants decide, so each engine tool you want has to be one of them (PUT /v1/agents/{id}/tools).
ArgumentWhat it is
datasetOptional. The dataset to read, by id or by its exact name, matched case-insensitively among the datasets the caller can read — a project's files are named by the project. Omitted, the run's own dataset_id is used; with neither, the run thread's project, if it has one; with none of the three the call is refused.
tableThe table to read. Optional with question — the engine picks the table the question fits — and required with sql. Without dataset, a project-scoped run resolves the name across every dataset of the project.
questionWhat to compute, in plain words.
sqlAn explicit read-only SELECT over one table.
calculationA typed row count or numeric sum, with optional population predicates, grouping, ranking and target selection. Requires table.
max_rowsOptional. Omitted means 2000, which is also the cap; a larger value is cut to 2000. A value below 1 is refused by argument validation — the schema declares a minimum of 1 — rather than raised to anything.

Exactly one of question, sql and calculation. A question is turned into a SELECT by the engine, validated, and repaired once if the statement is rejected; an explicit sql is validated and run as given, never repaired, and must name its table. Writing the statement is a model call like any other: it is billed to the organization and counted in the run's credits.

Extract one row per document

dataset.extract is the engine's third tool, and it is the other half of dataset.query: where a query reads a table that already exists, dataset.extract makes one out of documents that are not tables at all. It reads every PDF, Word, PowerPoint, text, Markdown or HTML document the selector covers, once each, with the fields you name — and writes one typed, source-linked row per document into a new table in the dataset, which the next call can query and a surface can bind exactly like any uploaded spreadsheet.

It is granted on the same reach gate as dataset.query and ui.surface: a run whose create body omits tools is offered it when the caller can read at least one dataset or project, and the other two shapes must ask for dataset.extract by name.

It writes, so it stops for approval. A run that calls it parks at awaiting_approval with a pending dataset.extract, and POST /v1/runs/{id}/approve resumes it — the same gate email.send gets, and for the same reason. See Handle approval.

It names one destination. A new table has no existing table to be found by, so the tool never searches: the run's own dataset_id is where it writes, or the attachment store of the run thread's project, and the caller must hold write on it. With neither, the call is refused dataset.extract writes a new table, so the task has to run in a project or name a dataset with dataset_id.

ArgumentWhat it is
tableRequired. The name of the table this extraction produces: lower-case letters, digits and underscores, starting with a letter or underscore, at most 63 characters.
fieldsRequired. 1 to 16 entries, each {name, type, description}. name follows the same SQL-safe rule and is unique within the schema; description is 1 to 200 characters and is what the model reading each document is shown.
whereOptional. Which of the dataset's documents this definition covers: filename (a glob over the base filename, e.g. *.pdf) and types (extensions, e.g. ["pdf","docx"]). Omitted means every non-tabular document.

A field's type is one of text, number, boolean, date and text[], and the type is enforced at the boundary rather than trusted:

  • number is a plain JSON number, and lands as DOUBLE.
  • boolean is true or false, and lands as BOOLEAN.
  • date is YYYY-MM-DD exactly, and lands as DATE.
  • text[] is a JSON array of strings, stored ", "-joined in one VARCHAR column.
  • text is a string, and lands as VARCHAR.

A value the document does not state is null, never guessed. A value that is stated but fails its declared type also becomes null, the failure is recorded on the row's error, and the row still counts as read — one bad field does not throw away the other fifteen.

Two column names are the table's own and cannot be used as field names: source_document (the document id the row came from) and source_file (its filename). values and evidence are reserved as well — they are the envelope's own keys. The projected table's columns are source_document, source_file, then your fields in declared order.

An extraction of a folder of CVs looks like this:

{
  "table": "candidates",
  "fields": [
    {"name": "full_name", "type": "text", "description": "the candidate's full name as written"},
    {"name": "years_experience", "type": "number", "description": "total professional experience in years"},
    {"name": "worked_in_finance", "type": "boolean", "description": "held a role at a bank, insurer or fund"},
    {"name": "languages", "type": "text[]", "description": "languages spoken"}
  ],
  "where": {"filename": "cv-*.pdf"}
}

The answer says only what is true when it is said — the definition exists and the documents are staked; the rows land as they are read:

{
  "table": "candidates",
  "fields": ["full_name", "years_experience", "worked_in_finance", "languages"],
  "documents": 5,
  "rows_ready": 0,
  "message": "Reading 5 documents into candidates. Rows land as documents are read; a follow-up on this thread can query and bind the table."
}

fields in the answer is the column NAMES, which is what a follow-up dataset.query needs. A tabular file is never a candidate whatever the selector says — a CSV or a spreadsheet is already a table.

The definition STANDS. It is not one batch: a document uploaded into the dataset afterwards joins it automatically, under the same schema, with no second call and no run. Calling the tool again with the same table, the same fields and the same where reports progress and stakes nothing new. Change any of them and the next generation opens: every matching document is re-read under the new schema, and the previous generation's table stays queryable until the new one publishes. At most 200 documents may be covered by one definition; more is refused, naming the count so you can narrow where.

Each extraction owns a resource in the dataset, so it appears in the resource listing and DELETE /v1/datasets/{id}/resources/{rid} removes the definition, its rows and its table together.

Arguments are refused the way a surface is — an error word and every problem located by JSON Pointer, so a model repairs the argument it got wrong:

{
  "error": "extraction refused",
  "problems": [
    {"path": "/args/fields/2/type", "message": "unknown type \"money\"; one of text, number, boolean, date, text[]"}
  ]
}

Read what an extraction has done

Three routes read a dataset's extractions. Each is mirrored under /v1/projects/{id}/... through the project's attachment store — read access for the two GETs, write access for the retry.

GET /v1/datasets/{id}/extractions lists the dataset's standing definitions with their progress:

{
  "extractions": [
    {
      "table": "candidates",
      "fields": [{"name": "full_name", "type": "text", "description": "the candidate's full name as written"}],
      "where": {"filename": "cv-*.pdf"},
      "generation": 1,
      "published": true,
      "model": "gpt-4.1",
      "run_id": "run_01J…",
      "credits_spent": 1840,
      "counts": {"queued": 0, "extracting": 0, "done": 4, "failed": 1, "skipped": 0},
      "created_at": "2026-09-19T09:12:04Z",
      "updated_at": "2026-09-19T09:13:41Z"
    }
  ]
}

published is true only when the queryable table IS this generation at this revision — a table left over from the previous schema, or one missing rows that have already finished, reads false. where carries only the keys the selector actually set, so an unrestricted definition's is {}.

GET /v1/datasets/{id}/extractions/{table}/rows?cursor= is one keyset page of 50 rows of the current generation, oldest first, with next_cursor empty on the last page:

{
  "rows": [
    {
      "id": "exr_01J…",
      "document_id": "doc_01J…",
      "source_file": "cv-amina.pdf",
      "state": "done",
      "values": {"full_name": "Amina Haddad", "years_experience": 8, "worked_in_finance": true, "languages": "French, Arabic, English"},
      "evidence": {"full_name": "Name: Amina Haddad", "years_experience": "Experience: 8 years", "worked_in_finance": "Finance: yes", "languages": "Languages: French, Arabic, English"},
      "flags": {},
      "error": "",
      "attempts": 1,
      "updated_at": "2026-09-19T09:13:40Z"
    }
  ],
  "next_cursor": ""
}

state is one of queued, extracting, done, failed and skipped (skipped with error: "over_cap" is a document past the 200-document ceiling). evidence is a verbatim quote of at most 200 characters per stated value, copied from the document — and it is checked: flags carries evidence_unverified when any quote does not appear in the document it came from, and truncated_input when the document was longer than the model was shown. Neither flag fails the row; both are there so you can see which values to trust. values, evidence and flags are always objects, never null.

POST /v1/datasets/{id}/extractions/{table}/retry puts back everything waiting can fix — the failed rows, and the rows that stalled on credit — and answers 202 with the number it re-queued:

{"restaked": 1}

While a worker still holds one of the table's rows it answers 409 extraction_in_progress with an extraction is in progress; retry when it has finished.

The two GETs answer 404 for a {table} that is not one of the dataset's extractions and for a dataset this caller cannot read — the same refusal an unknown id gets, so a listing never tells you that somebody else's dataset exists. The retry is a write, and answers by tier instead: 403 dataset_write_required for a caller who can read the dataset but not write into it, and 403 forbidden for one with no access to it at all.

Billing. The tool itself reads no document and calls no provider; it checks that the batch is affordable and refuses insufficient_credits or user_limit when it is not, creating nothing. The real cost is one model call per document, billed as it happens and reported on the definition's credits_spent. Work already accepted can still pause for credit — such rows stay queued with error: "insufficient_credits" and are picked up again within five minutes, or immediately by retry.

Count or rank with a typed calculation

Use calculation when the task needs an explicit count, total or rank whose scope must travel with the result. A count of completed transactions is:

{
  "table": "transactions",
  "calculation": {
    "metric": {"op": "count"},
    "population": [
      {"column": "status", "op": "eq", "value": "completed"}
    ]
  }
}

count counts matching rows and takes no column. sum requires one numeric column. Population predicates are applied before aggregation; the supported operators are eq, ne, in, gte and lt. A calculation accepts at most 16 predicates, and an in predicate accepts 1 to 100 values. Values are typed JSON literals, not SQL expressions.

This ranks six observed calendar months by net amount, smallest first, then returns August's row:

{
  "table": "transactions",
  "calculation": {
    "metric": {"op": "sum", "column": "net_amount_usd"},
    "population": [
      {"column": "transaction_date", "op": "gte", "value": "2026-03-01"},
      {"column": "transaction_date", "op": "lt", "value": "2026-09-01"}
    ],
    "group_by": {"column": "transaction_date", "transform": "month"},
    "rank": {"direction": "asc"},
    "target": "2026-08-01"
  }
}

The population fixes the rows being compared. target is applied only after those rows are grouped and, when requested, ranked, so it selects August without shrinking the six-month comparison to August. rank and target each require group_by, but either can be used without the other. The month transform accepts a DATE column only; timestamps are refused because they need an explicit timezone policy. Only months observed in the source are returned—missing months are not inserted.

Ascending or descending ranking uses SQL RANK: equal numeric values share a rank and leave a gap after the tie. Null group keys are excluded from a ranked population and counted in excluded_null_group_rows. A group whose sum is null remains visible when no target removes it, but its rank is null and it is not included in the ranked population_size. A sum with no finite value stays null rather than becoming zero.

The result uses stable columns: group_value when grouped, metric_value, and, when ranked, rank and population_size. The tool result also gains an optional server-owned calculation object:

{
  "query_id": "q_9f2c1ab40e57",
  "table": "transactions",
  "columns": ["group_value", "metric_value", "rank", "population_size"],
  "preview_rows": [["2026-08-01", 76856, 4, 6]],
  "preview_truncated": false,
  "capture_rows": 1,
  "total_rows": 1,
  "truncated": false,
  "calculation": {
    "definition": {
      "version": 1,
      "source": {
        "dataset_id": "8a73e7d1-5c91-4f38-9c16-55b16a4ad037",
        "table_id": "cb874f32-96c8-49fa-92b0-f46edc64fc2f",
        "table": "transactions"
      },
      "metric": {"op": "sum", "column": "net_amount_usd"},
      "population": [
        {"column": "transaction_date", "op": "gte", "value": "2026-03-01"},
        {"column": "transaction_date", "op": "lt", "value": "2026-09-01"}
      ],
      "group_by": {"column": "transaction_date", "transform": "month"},
      "rank": {"direction": "asc"},
      "target": "2026-08-01",
      "output_columns": [
        {"name": "group_value", "meaning": "group key"},
        {"name": "metric_value", "meaning": "row count or sum; null means no finite sum"},
        {"name": "rank", "meaning": "RANK among non-null groups with finite metrics; ties leave gaps"},
        {"name": "population_size", "meaning": "number of groups eligible for ranking before target selection"}
      ],
      "literal_text": {}
    },
    "execution": {
      "executed_at": "2026-09-14T12:00:00Z",
      "population_size": 6,
      "excluded_null_group_rows": 2,
      "target_outcome": "present"
    }
  },
  "usage": {"input_tokens": 0, "output_tokens": 0}
}

execution.population_size means matching source rows without grouping, observed groups with grouping alone, and groups eligible for ranking when rank is present. An empty ungrouped population is therefore 0. target_outcome is not_requested, present or absent; an absent target is reported there instead of as a fabricated row.

JSON parsers can round wide integers and long decimals. For every numeric predicate or target, definition.literal_text supplies canonical exact text at a JSON Pointer such as /population/0/value, /population/1/values/0 or /target. Use that text in a calculation disclosure. String, date and boolean literals use their ordinary values. The map is output-only: a tool call that supplies literal_text is refused. For example, an input predicate whose JSON number is 9007199254740993 produces {"/population/0/value":"9007199254740993"} even though a JavaScript parser rounds the numeric field itself.

The definition records what the engine executed. It does not certify the model's interpretation of the request, its prose, or a separate SQL query.

The model is shown a preview, not the whole capture:

{
  "query_id": "q_9f2c1ab40e57",
  "table": "orders",
  "columns": ["region", "shipped_late"],
  "preview_rows": [["EMEA", 412], ["AMER", 233]],
  "preview_truncated": false,
  "capture_rows": 2,
  "total_rows": 2,
  "truncated": false,
  "usage": {"input_tokens": 1180, "output_tokens": 64}
}

preview_rows holds at most 100 rows and is bounded by bytes as well, so a wide table is cut here — preview_truncated says so — rather than losing its query_id to the run's tool-result cap. capture_rows is how many rows the stored capture holds, truncated whether that capture holds fewer than the statement produced, and total_rows is null when the engine could not count them.

The whole capture is stored on the engine's side — up to 2000 rows and 2 MiB of JSON per query — and query_id is the handle a surface binds to. A task that also holds ui.surface can answer with an interface over these very rows, and GET /v1/surfaces/{revision_id} is where they are read back. No route returns a capture on its own: rows reach a client as part of the surface that binds them, never as a query you can address directly.

A refusal comes back as a tool result rather than an error, so the model can act on it and ask again:

{"error": "no dataset \"files\" you can read; datasets you can read: Sales"}

Both dataset refusals name what the caller could ask for instead: with nothing to read at all it is this task has no project and no dataset; start it in a project, or name a dataset you can read: Sales, Support tickets, and with a name it cannot read, the message above. When there is nothing to list, the first becomes this task has no project and no dataset, and you have no dataset you can name: start it in a project, add a CSV or Excel file to a project's files or datasets, or rename any that share a name, and the second ends with the same you have no dataset you can name — … clause.

A run started with project_id is told about tables instead, since its caller named no dataset and may not know one exists. The tables are listed grouped by the dataset each is in, with the project's own files written (files):

  • no table "invoices" in project "Quarterly review"; tables you can read: sales, orders ("Quarterly review"), regions ("Geo") — bounded to ten, with a trailing when there are more. A project's own files are named by the project, which is what its hidden store is called;
  • table "sales" is in more than one of the project's datasets: "Quarterly review" (files), "Geo"; name the dataset too — send dataset as well as table. Every dataset either sentence names 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;
  • project "Quarterly review" has no table you can read; datasets you can read: Sales — the project holds no CSV or spreadsheet this run can read.

A reference in a project that the caller cannot read in their own right is simply not in the scope: it is never read and never named. A project-scoped run therefore reaches no more than a chat in that project does.

The tool also refuses when sql arrives without table, when the statement is not a single SELECT, when a generated statement could not be repaired, when the query exceeded the sidecar's thirty-second budget — ask a narrower one — when the dataset holds no tables at all (dataset: has no tables), when the named table is not one of that dataset's tables, and when that name matches two of its documents, which a re-ingested dataset can hold when two generations carry one sheet name and neither is authoritative.

One refusal is worth reading for its remedy rather than its cause: when two datasets the caller can read share the name given, the tool answers more than one dataset is called Orders; name it by id. Send the dataset's id in dataset — or start the task with dataset_id — instead of the name.

Repeating the same call in the same place of the same run answers from what was stored rather than querying again. Change the arguments and it is a fresh query.

Answer with a surface

ui.surface is the engine's second tool. Where dataset.query reads a table, ui.surface answers the task with an interface over the rows it has already read — filters, a table, charts, headline numbers — instead of a paragraph describing them.

It takes one argument, spec: the whole surface as

{
  "spec": {
    "root": "page",
    "elements": {
      "page": {
        "type": "Section",
        "props": {"title": "Late shipments"},
        "children": ["by-region"],
        "visible": true
      },
      "by-region": {
        "type": "Chart",
        "props": {
          "chartType": "bar",
          "data": {"$query": "q_9f2c1ab40e57"},
          "xKey": "region",
          "yKey": "shipped_late"
        },
        "children": [],
        "visible": true
      }
    }
  }
}

Every element carries all four of type, props, children and visible, and no key outside them. visible is true or a condition on interaction state — {"$state": "/ui/expanded"}, optionally with not, eq or neq.

The vocabulary is fixed: Section, Row, Column, Expanded, Divider, DataList, FilterBar, Chart, StatTile, Badge, Tabs and Dialog. A spec is data — no expression language, no URLs, no HTML — and it carries no rows of its own. Every DataList, Chart and StatTile binds a query the same thread already made, as {"$query": "<query_id>"}, and may name only columns that query returned.

Interaction state lives under /ui/: a FilterBar control writes a /ui/ path, and a DataList, Chart or StatTile filter reads it back as its value with {"$state": "/ui/country"} — one control drives all three at once. A filter selects among the captured rows a client already holds, and the capture is sized so that a filter over a small table is complete, so no further query is made. There are no actions, buttons or forms in this vocabulary; what a person should do next belongs in the task's own answer.

The surface is bounded, and the engine refuses one that is not: at most 60 elements, 8 levels deep and 12 bound queries, with a root that is a Section and is itself one of the elements, exactly one parent per element, and nothing left unreachable from the root. A StatTile or a Chart has to stand on a query that was not truncated, so a whole-table number is a dataset.query with GROUP BY, SUM or COUNT rather than arithmetic over 2000 captured rows. What the catalog's rules advise rather than enforce is the shape: keep the surface small — a KPI row, one or two charts and a list.

Since catalog 1.2.0 (2026-09-18) the engine also refuses numbers in surface text. Every text prop — a Section title, subtitle or body, a StatTile, Badge, column, series, control or tab label, a Dialog title — may not carry a numeral, except a year (2026), an ISO date (2026-08, 2026-08-31) or a quarter (Q1Q4, T1T4), nor a cardinal or ordinal number word in English or French ("five", "fourth", "cinq", "première"). A written StatTile.delta is refused too. Numbers reach the reader only through bound components: a StatTile value, a Chart series, a DataList cell. FilterBar options and placeholder are filter values and are not read. The refusal names the prop and quotes the first offender:

{"path": "/spec/elements/banner/props/label",
 "message": "label contains a number (\"five\"); surface text carries no numbers — put the value on a StatTile bound to a query"}

The rule's data — the regex sources and both word lists — ships in the bundle as text-rule.json, and the bundle digest now covers the schema, the rules and that file, in that order. It runs at acceptance only; a refresh never re-checks text, so a revision stored under 1.1.0 keeps refreshing.

What the model is told

The engine accepts a surface; it does not render one. A successful call answers:

{
  "revision_id": "9f1c7a6e-3b90-4d1a-8f22-6a5b0c4e7d11",
  "revision": 2,
  "accepted": true,
  "message": "Revision 2 of this thread's surface is accepted and stored. A follow-up on this thread refines it."
}

Revisions are numbered per conversation, so a follow-up run that answers with a surface again is revision 3. A refinement is a whole new surface, never a patch — hand in the complete spec each time. Acceptance runs no query and makes no model call: it copies the statements and the captures the thread's dataset.query calls already produced.

A refusal is a tool result the model can act on, with every problem located by JSON path under /spec and all of them reported in one pass:

{
  "error": "surface refused",
  "problems": [
    {"path": "/spec/elements/by-region/props/yKey", "message": "column \"late\" is not in q_9f2c1ab40e57; columns: region, shipped_late"},
    {"path": "/spec/elements/late-tile/props/value/data", "message": "q_4c81ea92f003 is truncated; aggregate it in SQL and bind the tile to that query"}
  ]
}

A problem with the argument as a whole carries the path /spec exactly. The argument is capped at 256 KiB and that cap is checked before the JSON is parsed. Two refusals are worth knowing about as an integrator, because they are about the thread rather than about the spec: binding a query_id that two finished queries of the thread both carry is refused (run dataset.query again and bind the new id), and a surface whose bound captures exceed 2048 KiB in total is refused naming the three largest queries to narrow.

Read the surface a task answered with

Every run view carries the surface that run accepted:

{
  "has_surface": true,
  "surface": {
    "revision_id": "9f1c7a6e-3b90-4d1a-8f22-6a5b0c4e7d11",
    "revision": 2,
    "catalog_version": "1.1.0",
    "created_at": "2026-09-11T09:14:02Z",
    "refreshed_at": null
  }
}

Both fields are on every run view — GET /v1/runs/{id}, the listing, and the approve and deny responses — so a run that answered with no surface is "surface": null beside "has_surface": false rather than a missing key you have to guess at. refreshed_at is null until a refresh of that revision has succeeded. POST /v1/runs/{run_id}/cancel deliberately carries neither: it answers the narrower run-metadata shape.

Three routes read the surface itself, and all three are the revision owner's for every role — a revision that is not yours is 404 rather than 403, a malformed id answers exactly as a well-formed stranger's id does, and a thread that is not yours lists nothing rather than refusing:

GET  /v1/surfaces/{revision_id}
POST /v1/surfaces/{revision_id}/refresh
GET  /v1/conversations/{conversation_id}/surfaces

GET /v1/surfaces/{revision_id} is the whole thing — spec is the very document the task handed in, stored as it was accepted:

{
  "id": "9f1c7a6e-3b90-4d1a-8f22-6a5b0c4e7d11",
  "run_id": "…",
  "conversation_id": "…",
  "revision": 2,
  "catalog_version": "1.1.0",
  "catalog_digest": "aff0601351e626ab…",
  "spec": {
    "root": "page",
    "elements": {
      "page": {
        "type": "Section",
        "props": {"title": "Late shipments"},
        "children": ["by-region"],
        "visible": true
      },
      "by-region": {
        "type": "Chart",
        "props": {
          "chartType": "bar",
          "data": {"$query": "q_9f2c1ab40e57"},
          "xKey": "region",
          "yKey": "shipped_late"
        },
        "children": [],
        "visible": true
      }
    }
  },
  "queries": [
    {"query_id": "q_9f2c1ab40e57", "table": "orders", "columns": ["region", "shipped_late"]}
  ],
  "capture": {
    "q_9f2c1ab40e57": {
      "rows": [["EMEA", 412], ["AMER", 233]],
      "row_count": 2,
      "total_rows": 2,
      "truncated": false
    }
  },
  "refresh": null,
  "created_at": "2026-09-11T09:14:02Z"
}

Four things about that body are worth stating exactly.

Calculation metadata follows the selected capture. A calculation query adds the same calculation object to its queries[] entry and its keyed capture entry. A successful refresh recompiles the saved versioned definition against the current table schema and replaces the rows, columns, source provenance and execution facts together. A failed refresh keeps the entire last good capture and its metadata. Never combine refreshed rows with an older executed_at, population size or target outcome.

The field is additive and optional. Queries made with question or sql, and rows stored before calculation metadata existed, have no calculation; render their rows as before. If an older stored definition has no literal_text or has it as null, exact numeric spelling is unavailable—do not reconstruct it from a parsed JSON number.

The statement is not a field. A query is projected to query_id, table and columns. The SQL a run wrote is visible to that run's owner in the thread's own turns, like any other tool argument, and this route does not repeat it. That is a rule about the response shape, not a promise that no SQL text appears anywhere in the body — a failed refresh's problem messages are the sidecar's own refusals and routinely quote the fragment they could not parse. They are rendered verbatim, deliberately: every route here is owner-scoped, the same person reads the whole statement in the run's turns, and classing that message would cost the one detail that explains the failure. Technical failures are the opposite case and carry no cause at all — see below.

table is a name, never a table id. Re-ingesting a file keeps the name and replaces the id, and that is precisely the case a refresh exists for.

Read columns from queries[], not from the capture. queries[].columns is the column list the rows you are given are actually in: the shown capture's own list when it carries one, and the revision's recorded list when it does not. A re-uploaded sheet can return the same columns reordered or with one inserted; rendering a refresh's rows under the revision's original header would mislabel every value and look plausible doing it.

Absences are narrow. capture is always an object ({} for a surface that binds no query), queries[].columns is always a list, and inside a refresh problems and notices are always lists — [] on a refresh that succeeded and on one that found no new tables. The two real nulls are refresh (never refreshed) and finished_at (queued or running). Inside a capture, total_rows is a number and is -1 when the engine did not count the rows; the null you may have seen is on the dataset.query tool result the model reads, not here.

GET /v1/conversations/{conversation_id}/surfaces is the thread's revisions, newest first, metadata only — no spec and no capture, because neither is read for the listing:

{"items": [{"id": "…", "run_id": "…", "conversation_id": "…", "revision": 2, "catalog_version": "1.1.0", "created_at": "…", "refresh": null}]}

It answers the newest 200 and carries no cursor: a longer thread does not list its oldest revisions, and the shape has no marker saying so.

Refresh a surface's rows

A revision is immutable, but its data does not have to be. POST /v1/surfaces/{revision_id}/refresh re-runs the revision's stored statements against the current tables:

{"refresh_id": "3a18e4c2-…", "state": "queued"}

That is a 202, not a 200: a refresh can run twelve statements of up to thirty seconds each, so the row is accepted and the work happens off-request. Poll GET /v1/surfaces/{revision_id} and read its refresh field, exactly as you poll a run. One refresh at a time per revision — a second while one is in flight is 409, and the id of the refresh already running is inside the typed envelope's message (a refresh is already in progress: <refresh-id>), because the envelope has no other slot for it. A refresh whose worker died is reaped by the next GET or POST on the revision, roughly sixteen minutes after it started, so that 409 is never permanent.

A refresh makes no model call and takes no billing hold, so it spends no credits. No SQL is written again: the statements are the ones the revision recorded, resolved against the tables by name, as the revision's owner without roles. Each query is re-run against the dataset its own row names, so a revision made by a project-scoped task can span several of that project's datasets and each one is re-read where it lives.

A finished refresh reads back like this:

{
  "refresh": {
    "id": "3a18e4c2-…",
    "state": "failed",
    "started_at": "2026-09-11T10:02:11Z",
    "finished_at": "2026-09-11T10:02:13Z",
    "problems": [
      {"query_id": "q_9f2c1ab40e57", "message": "the dataset is no longer readable by you; ask its owner to share it again"}
    ],
    "notices": []
  }
}
  • A failed refresh keeps the last good data. capture still holds the rows of the newest refresh that succeeded — or the revision's own, if none has — and queries[].columns still describes those very rows. The failure is reported beside them rather than instead of them, so a page can draw the surface it has and say that the newest attempt did not land. No partial capture is ever substituted: half a surface's queries is not a result.
  • Problems are collected in one pass, one per reason, attributed to a query when the reason belongs to one. A dataset that has been unshared fails every query of it with one sentence and runs no statement at all. A renamed column, or a query that has become truncated under a tile or a chart, fails the refresh in the same words acceptance would have used.
  • A technical failure says only a class. the refresh did not finish: technical failure or … unavailable, never the underlying message — that prose can carry a column name or a fragment of a statement. It is appended to the reasons the refresh had already learned rather than replacing them, so an unshared dataset is still named when a later query hits an outage. The refresh's own five-minute clock reports the refresh timed out after 5m0s.
  • notices name unbound tables in the revision's datasets (new tables: orders_2026-09), so a follow-up can use them, and they ride a failed refresh too — a renamed sheet is exactly where the new name is the fact that explains the failure. They are computed only over datasets that the refresh could actually list, which is why the example above carries none: its one dataset is the one that became unreadable, and a dataset that cannot be listed names no table. A revision spanning several of a project's datasets can therefore fail on one of them and still carry notices from the others. One further limit: the datasets are the revision's own queries' datasets, so a dataset attached to the thread's project after the revision was made is not announced.

The catalog version and digest

Every revision records the catalog_version and catalog_digest it was validated against — this release vendors version 1.2.0; revisions stored before 2026-09-18 carry 1.1.0 — and is never rewritten. Nothing migrates a stored spec to a later bundle. A client that draws surfaces should compare these against the bundle it holds and refuse a version it cannot draw, naming it, rather than guessing at an element type it does not know. Additive catalog changes keep older revisions drawable: a client should hold a small table of the versions it can draw, each with its digest, rather than one pinned version — Deeplinq's own console draws 1.1.0 and 1.2.0 and applies the text rule only from 1.2.0.

These routes are the wire contract, and they are complete: a client can read a revision, its rows and its refresh state today. Rendering the spec in Deeplinq's own console is a following stage — until it ships, a person reads the task's text answer and an integrator reads the surface through the routes above.

Continue a thread

Every run answers with a conversation_id. Send it back when you create the next run and that run joins the same conversation instead of opening one of its own: the earlier runs' messages, tool calls and tool results are the new run's history, so a follow-up can act on what an earlier run read without reading it again.

curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/v1/runs" -d '{
  "message":"Now add that flight to my calendar.",
  "model":"gpt-5-mini",
  "conversation_id":"'"$CONVERSATION_ID"'"
}'

Both create routes take the field. The conversation must be one of your own that a run opened: another person's, another organization's or a deleted one is 404, a plain chat conversation is 400, and a follow-up while a run on the thread is still queued, running or awaiting_approval is 409 — one run at a time on a thread.

GET /v1/runs?conversation_id={conversation_id} lists the thread's runs, newest first, paged like the rest of the listing; reverse the page to read the thread in order.

Handle approval

When status is awaiting_approval, inspect the pending action in the owner-only run view, then:

curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/v1/runs/$RUN_ID/approve" \
  -d '{"note":"The draft is safe to send."}'

Or deny:

POST /v1/runs/{run_id}/deny
{"note":"Do not contact this recipient."}

Approval, state transition, and continuation-job creation commit atomically. Denial becomes a tool result so the model can adapt.

Bounds and cancellation

Every run is bounded by:

  • maximum turns;
  • an optional micro-USD credit budget;
  • a wall-clock deadline;
  • an approval timeout;
  • a maximum tool-result size.

Cancel with:

POST /v1/runs/{run_id}/cancel

Cancellation stops after in-flight work completes. Explicit terminal reasons include budget, turn, timeout, guardrail, tool, and cancellation outcomes.

Events

Runs emit content-free events including run.started, run.approval_required, run.tool_unavailable, run.succeeded, and run.failed. The shared webhook delivery state machine can deliver them without placing prompts, tool arguments, or results in the event payload.

On this page