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 tool 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; a caller with no readable dataset is not offered dataset.query at all, rather than being offered a tool whose every call it would have to refuse. 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 the 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 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 dataset.query 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.
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 and sql. 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.

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, but no route returns it today: query_id is the handle a later surface will bind to, not something an integrator can read back, so there is no endpoint to go looking for.

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.

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